diff --git a/.env_sample b/.env_sample index 9c00eafe7..0f318ef80 100644 --- a/.env_sample +++ b/.env_sample @@ -88,6 +88,13 @@ ENABLE_SIGN_UP=True ENABLE_SIGN_IN=True +# ----------------------------------------------------------------------------- +# Enable or disable the External Competitions feature (button, page, API, +# and the daily fetch task). Off by default - intended for the main instance only. +# ----------------------------------------------------------------------------- +EXTERNAL_COMPETITIONS_ENABLED=False + + # # S3 storage example # STORAGE_TYPE=s3 # AWS_ACCESS_KEY_ID=12312312312312312331223 diff --git a/documentation/docs/Developers_and_Administrators/External-Competitions.md b/documentation/docs/Developers_and_Administrators/External-Competitions.md new file mode 100644 index 000000000..14d376d7f --- /dev/null +++ b/documentation/docs/Developers_and_Administrators/External-Competitions.md @@ -0,0 +1,63 @@ +External Competitions lets a Codabench instance show a browsable list of public competitions hosted on *other* Codabench and CodaLab instances, fetched and synced automatically once a day. It's off by default and intended for the main `codabench.org` instance rather than self-hosted deployments. + +## For Codabench administrators + +### Enabling the feature + +Set the following in your `.env` file: + +``` +EXTERNAL_COMPETITIONS_ENABLED=True +``` + +This turns on the External Competitions page, a banner/link to it on the public benchmarks and competitions page, the two read-only API endpoints, and the daily Celery beat task that fetches and syncs competitions. Leaving it `False` (the default) disables all of it - the API endpoints return a 404, and the page and the banner linking to it aren't shown. + +### Adding a platform to sync from + +Platforms are managed from the Django admin, under **External Competitions -> External platforms -> Add**. Each platform needs: + +| Field | Description | +|---|---| +| Name | Display name shown on the competition tiles (e.g. "Codabench @ LISN") | +| Platform type | `Codabench instance` or `CodaLab instance` | +| Competitions fetch URL | The API endpoint to get that platform's list of public competitions | +| Competition base URL | The base URL used to create complete link for each competition | +| Active | Unchecking this skips the platform in the daily fetch (already-synced competitions stay visible - see note below) | + +Once saved, the platform is picked up by the next scheduled fetch (or trigger one manually, see below). + +### How the sync works + +`fetch_external_competitions` (`src/apps/external_competitions/fetch_sync.py`) runs once a day via Celery beat. For each active platform it calls the fetcher matching its platform type (`codabench_fetcher.py` or `codalab_fetcher.py`) and then diffs the result against what's already stored: + +- Competitions present in the fetch are created or updated (matched by `competition_url`). +- Competitions no longer present in the fetch are deleted. + +Each run writes an `ExternalFetchLog` entry (visible in the admin) recording the outcome (`SUCCESS`/`FAILURE`), counts (`new_count`/`updated_count`/`deleted_count`), and, on failure, an error message - check there first if a platform's competitions look stale or missing. + +!!! note + Deactivating a platform (`is_active=False`) only stops it from being fetched going forward - competitions already synced from it stay visible on the public list until manually removed. + +To trigger a fetch immediately instead of waiting for the daily schedule: + +```bash +docker compose exec django ./manage.py shell -c "from external_competitions.fetch_sync import fetch_external_competitions; fetch_external_competitions()" +``` + +## For platform administrators + +If you run your own Codabench or CodaLab instance and would like your public competitions to be discoverable on `codabench.org`'s External Competitions page, you can request to be added. + +### Registering your platform + +Send an email to **info@codabench.org** with the subject **"Platform Registration for External Competitions"**, including: + +- **Your platform's name and type** (Codabench, CodaLab instance, or other). +- **Your competitions fetch URL** - the API endpoint we'll use once a day to retrieve your list of public competitions (for example, a Codabench instance's `.../api/competitions/public/`). This endpoint must be publicly reachable without authentication, since the fetch runs unattended. +- **Your competition base URL** - the base URL used to build the link back to each competition on your site, so visitors clicking a competition on `codabench.org` land on the right page on yours. + +Please share as much detail as you can, **especially about the fetch URL** - pagination behavior, response format, expected size of the response, rate limits, or anything else likely to affect an automated daily fetch. The more we know upfront, the more reliably we can keep your competitions in sync. + +### Unregistering your platform + +To have your platform's competitions removed or paused, email **info@codabench.org** asking us to deactivate (or delete) fetching for your platform, including your platform's name and/or URL so we can identify it. diff --git a/documentation/docs/Developers_and_Administrators/How-to-deploy-Codabench-on-your-server.md b/documentation/docs/Developers_and_Administrators/How-to-deploy-Codabench-on-your-server.md index aa11532d4..2d696bdb3 100644 --- a/documentation/docs/Developers_and_Administrators/How-to-deploy-Codabench-on-your-server.md +++ b/documentation/docs/Developers_and_Administrators/How-to-deploy-Codabench-on-your-server.md @@ -243,6 +243,8 @@ You can update these by: 1. Replacing the logos in `src/static/img/` folder 2. Updating the code in `src/templates/pages/home.html` to point to the right websites of your organizations +!!! tip + Now that your instance is up and running, consider registering it with `codabench.org` as an [External Competitions](External-Competitions.md) platform. Once registered, your instance's public competitions are also listed on `codabench.org`'s own External Competitions page, giving them more visibility. See the [registration instructions](External-Competitions.md#registering-your-platform) for details. ## Frequently asked questions (FAQs) diff --git a/documentation/zensical.toml b/documentation/zensical.toml index a2b056c92..164a5e378 100644 --- a/documentation/zensical.toml +++ b/documentation/zensical.toml @@ -54,6 +54,7 @@ nav = [ {"Self-Hosters" = [ {"How to Deploy a Server" = "Developers_and_Administrators/How-to-deploy-Codabench-on-your-server.md"}, {"Administrative Procedures" = "Developers_and_Administrators/Administrator-procedures.md"}, + {"External Competitions" = "Developers_and_Administrators/External-Competitions.md"}, {"Backups - Automating Creation and Restoring" = "Developers_and_Administrators/Creating-and-Restoring-from-Backup.md"}, {" Upgrading Codabench" = [ "Developers_and_Administrators/Upgrading_Codabench/index.md", diff --git a/src/apps/api/serializers/external_competitions.py b/src/apps/api/serializers/external_competitions.py new file mode 100644 index 000000000..49361cbcc --- /dev/null +++ b/src/apps/api/serializers/external_competitions.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from external_competitions.models import ExternalCompetition, ExternalPlatform + + +class ExternalCompetitionSerializer(serializers.ModelSerializer): + platform_name = serializers.CharField(source='platform.name', read_only=True) + platform_type = serializers.CharField(source='platform.platform_type', read_only=True) + + class Meta: + model = ExternalCompetition + fields = ( + 'id', + 'name', + 'description', + 'image_url', + 'organizer_name', + 'competition_url', + 'competition_created_when', + 'competition_started_when', + 'platform', + 'platform_name', + 'platform_type', + ) + + +class ExternalPlatformFilterSerializer(serializers.ModelSerializer): + class Meta: + model = ExternalPlatform + fields = ('id', 'name', 'platform_type') diff --git a/src/apps/api/tests/test_external_competitions.py b/src/apps/api/tests/test_external_competitions.py new file mode 100644 index 000000000..6879212fc --- /dev/null +++ b/src/apps/api/tests/test_external_competitions.py @@ -0,0 +1,212 @@ +import importlib + +from django.test import TestCase, override_settings +from django.urls import clear_url_caches +from rest_framework.test import APIClient + +from external_competitions.models import ExternalPlatform +from factories import ExternalCompetitionFactory, ExternalPlatformFactory + + +def _reload_api_urls(): + # api/urls.py only adds the external_competitions paths to urlpatterns when + # it first runs, based on the setting's value at that moment. To pick up a + # changed setting, we need Django to re-run that file - reloading the module + # does that. But reloading api.urls by itself isn't enough: the root urls.py + # did `path('api/', include('api.urls'))`, and that include() already built a + # URLResolver object which cached the old urlpatterns list from api.urls the + # first time it ran. So we also reload the root urls module, which re-runs + # include('api.urls') and builds a fresh resolver pointing at the new list. + # clear_url_caches() then drops Django's cached lookup of the whole urlconf, + # so the next request resolves routes against these freshly reloaded modules. + import api.urls + importlib.reload(api.urls) + import urls + importlib.reload(urls) + clear_url_caches() + + +class ExternalCompetitionsApiFunctionalTests(TestCase): + """ + Covers the endpoints' behavior with the feature flag on. `api/urls.py` only + registers these paths at import time when EXTERNAL_COMPETITIONS_ENABLED is True, + so the flag is forced on and the urlconf reloaded for the lifetime of this class. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # override_settings only patches the setting value - it doesn't touch + # api/urls.py's already-built urlpatterns, since those were compiled once + # at import time (before any test ran). We store the override on cls (not + # self) because setUpClass/tearDownClass are classmethods that run once for + # the whole class, outside of any test instance, and both need to reference + # the same override object to enable/disable it. + cls._settings_override = override_settings(EXTERNAL_COMPETITIONS_ENABLED=True) + cls._settings_override.enable() + # Force api/urls.py (and the root urlconf that includes it) to re-run now + # that the setting is True, so these endpoints actually get registered. + _reload_api_urls() + + @classmethod + def tearDownClass(cls): + cls._settings_override.disable() + # Reload again with the real setting restored, so api.urls doesn't leak + # the forced-True urlpatterns into whatever test module runs next. + _reload_api_urls() + super().tearDownClass() + + def setUp(self): + self.client = APIClient() + + self.codabench_platform = ExternalPlatformFactory( + name='Some Codabench', + platform_type=ExternalPlatform.PLATFORM_TYPE_CODABENCH, + ) + self.codalab_platform = ExternalPlatformFactory( + name='Some CodaLab', + platform_type=ExternalPlatform.PLATFORM_TYPE_CODALAB, + ) + self.inactive_platform = ExternalPlatformFactory( + name='Inactive Platform', + platform_type=ExternalPlatform.PLATFORM_TYPE_CODABENCH, + is_active=False, + ) + + self.competition1 = ExternalCompetitionFactory( + platform=self.codabench_platform, + name='AI Challenge', + description='An AI competition', + organizer_name='Jane Doe', + ) + self.competition2 = ExternalCompetitionFactory( + platform=self.codalab_platform, + name='Vision Contest', + ) + self.inactive_platform_competition = ExternalCompetitionFactory( + platform=self.inactive_platform, + name='Old Contest', + ) + + def test_list_returns_expected_fields(self): + """ + Calls the list endpoint and checks the response is a 200 containing one + of the competitions we created, with every field holding the right value. + """ + response = self.client.get('/api/external_competitions/') + + self.assertEqual(response.status_code, 200) + result = next(r for r in response.data['results'] if r['id'] == self.competition1.id) + self.assertEqual(result['name'], 'AI Challenge') + self.assertEqual(result['description'], 'An AI competition') + self.assertEqual(result['organizer_name'], 'Jane Doe') + self.assertEqual(result['competition_url'], self.competition1.competition_url) + self.assertEqual(result['platform'], self.codabench_platform.id) + self.assertEqual(result['platform_name'], 'Some Codabench') + self.assertEqual(result['platform_type'], ExternalPlatform.PLATFORM_TYPE_CODABENCH) + self.assertIn('image_url', result) + self.assertIn('competition_created_when', result) + self.assertIn('competition_started_when', result) + + def test_list_pagination_shape(self): + """ + Calls the list endpoint and checks the response has the pagination fields + (count, next, previous, page_size, results), not just a plain list. + """ + response = self.client.get('/api/external_competitions/') + + self.assertEqual(response.status_code, 200) + for key in ('count', 'next', 'previous', 'page_size', 'results'): + self.assertIn(key, response.data) + + def test_search_filter(self): + """ + Searches for "vision" and checks only the competition matching that term + comes back in the results, and the other competition is left out. + """ + response = self.client.get('/api/external_competitions/?search=vision') + + self.assertEqual(response.status_code, 200) + ids = [r['id'] for r in response.data['results']] + self.assertIn(self.competition2.id, ids) + self.assertNotIn(self.competition1.id, ids) + + def test_platform_filter(self): + """ + Filters by one platform's id and checks only that platform's competition + comes back, while the other platform's competition is left out. + """ + response = self.client.get(f'/api/external_competitions/?platform={self.codabench_platform.id}') + + self.assertEqual(response.status_code, 200) + ids = [r['id'] for r in response.data['results']] + self.assertIn(self.competition1.id, ids) + self.assertNotIn(self.competition2.id, ids) + + def test_platform_filter_accepts_comma_separated_ids(self): + """ + Filters by both platforms' ids joined with a comma, and checks that both + platforms' competitions come back in the results. + """ + response = self.client.get( + f'/api/external_competitions/?platform={self.codabench_platform.id},{self.codalab_platform.id}' + ) + + self.assertEqual(response.status_code, 200) + ids = [r['id'] for r in response.data['results']] + self.assertIn(self.competition1.id, ids) + self.assertIn(self.competition2.id, ids) + + def test_competitions_from_inactive_platform_still_listed(self): + """ + Checks that a competition from a deactivated platform still shows up in + the list - being inactive only skips future fetches, not visibility. + """ + response = self.client.get('/api/external_competitions/') + + self.assertEqual(response.status_code, 200) + ids = [r['id'] for r in response.data['results']] + self.assertIn(self.inactive_platform_competition.id, ids) + + def test_platforms_endpoint_returns_all_platforms_unpaginated(self): + """ + Calls the platforms endpoint and checks it returns every platform, active + or not, as a plain list (no pagination), sorted alphabetically by name. + """ + response = self.client.get('/api/external_competitions/platforms/') + + self.assertEqual(response.status_code, 200) + names = [p['name'] for p in response.data] + self.assertIn('Some Codabench', names) + self.assertIn('Some CodaLab', names) + self.assertIn('Inactive Platform', names) + self.assertEqual(names, sorted(names)) + + +class ExternalCompetitionsUrlGatingTests(TestCase): + """ + Covers that the endpoints only exist when EXTERNAL_COMPETITIONS_ENABLED is True, + isolated from the functional tests above so each test controls its own flag state. + """ + + def setUp(self): + self.client = APIClient() + + def tearDown(self): + # Restore api.urls to match the real (non-overridden) settings, so later + # test modules in the same run aren't affected by our reloads. + _reload_api_urls() + + def test_urls_return_404_when_disabled(self): + with override_settings(EXTERNAL_COMPETITIONS_ENABLED=False): + _reload_api_urls() + + self.assertEqual(self.client.get('/api/external_competitions/').status_code, 404) + self.assertEqual(self.client.get('/api/external_competitions/platforms/').status_code, 404) + + def test_urls_return_200_when_enabled(self): + with override_settings(EXTERNAL_COMPETITIONS_ENABLED=True): + _reload_api_urls() + + self.assertEqual(self.client.get('/api/external_competitions/').status_code, 200) + self.assertEqual(self.client.get('/api/external_competitions/platforms/').status_code, 200) diff --git a/src/apps/api/urls.py b/src/apps/api/urls.py index 640b8a954..e5c006d8e 100644 --- a/src/apps/api/urls.py +++ b/src/apps/api/urls.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.conf.urls import include from django.urls import path @@ -13,6 +14,7 @@ analytics, competitions, datasets, + external_competitions, profiles, leaderboards, submissions, @@ -76,3 +78,9 @@ # Include this at the end so our URLs above run first, like /datasets/completed// before /datasets// path('', include(format_suffix_patterns(router.urls, allowed=['html', 'json', 'csv', 'zip']))), ] + +if settings.EXTERNAL_COMPETITIONS_ENABLED: + urlpatterns += [ + path('external_competitions/', external_competitions.ExternalCompetitionListView.as_view(), name='external_competition_list'), + path('external_competitions/platforms/', external_competitions.ExternalPlatformListView.as_view(), name='external_platform_list'), + ] diff --git a/src/apps/api/views/external_competitions.py b/src/apps/api/views/external_competitions.py new file mode 100644 index 000000000..7f4bc9b1c --- /dev/null +++ b/src/apps/api/views/external_competitions.py @@ -0,0 +1,44 @@ +from rest_framework import generics +from rest_framework.filters import SearchFilter +from rest_framework.permissions import AllowAny + +from api.pagination import LargePagination +from api.serializers.external_competitions import ExternalCompetitionSerializer, ExternalPlatformFilterSerializer +from external_competitions.models import ExternalCompetition, ExternalPlatform + + +class ExternalCompetitionListView(generics.ListAPIView): + serializer_class = ExternalCompetitionSerializer + permission_classes = (AllowAny,) + pagination_class = LargePagination + filter_backends = (SearchFilter,) + search_fields = ('name', 'description', 'organizer_name') + + def get_queryset(self): + # NOTE + # platform.is_active only controls whether the fetch task pulls from that + # platform - it doesn't hide already-fetched competitions from the public list. + # If in the future you don't want to show competitions from non active platfroms, + # Add a filter to the query below: `.filter(platform__is_active=True)` + queryset = ExternalCompetition.objects.select_related('platform') + + # Comma-separated list of platform ids, e.g. ?platform=1,2 + platform_ids = self.request.query_params.get('platform') + if platform_ids: + queryset = queryset.filter(platform_id__in=platform_ids.split(',')) + + return queryset + + +class ExternalPlatformListView(generics.ListAPIView): + serializer_class = ExternalPlatformFilterSerializer + permission_classes = (AllowAny,) + pagination_class = None + + def get_queryset(self): + # NOTE + # Not filtering by is_active: a deactivated platform's competitions still show + # in the list above, so it must stay selectable as a filter option here too. + # If in the future you don't want to show non active platfroms, + # Add a filter to the query below: `.filter(is_active=True)` + return ExternalPlatform.objects.order_by('name') diff --git a/src/apps/external_competitions/__init__.py b/src/apps/external_competitions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/external_competitions/admin.py b/src/apps/external_competitions/admin.py new file mode 100644 index 000000000..6ee7caf98 --- /dev/null +++ b/src/apps/external_competitions/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin + +from external_competitions.models import ExternalPlatform, ExternalCompetition, ExternalFetchLog + + +class ExternalPlatformAdmin(admin.ModelAdmin): + list_display = ['id', 'name', 'platform_type', 'competitions_fetch_url', 'competition_base_url', 'is_active', 'created_when'] + list_filter = ['platform_type', 'is_active'] + search_fields = ['name', 'competitions_fetch_url', 'competition_base_url'] + + +class ExternalCompetitionAdmin(admin.ModelAdmin): + list_display = ['id', 'name', 'platform', 'organizer_name', 'competition_created_when', 'updated_when'] + list_filter = ['platform'] + search_fields = ['name', 'organizer_name', 'competition_url'] + + +class ExternalFetchLogAdmin(admin.ModelAdmin): + list_display = ['id', 'platform', 'started_at', 'finished_at', 'status', 'total_fetched', 'new_count', 'updated_count', 'deleted_count'] + list_filter = ['status', 'platform'] + + +admin.site.register(ExternalPlatform, ExternalPlatformAdmin) +admin.site.register(ExternalCompetition, ExternalCompetitionAdmin) +admin.site.register(ExternalFetchLog, ExternalFetchLogAdmin) diff --git a/src/apps/external_competitions/apps.py b/src/apps/external_competitions/apps.py new file mode 100644 index 000000000..d5b572d17 --- /dev/null +++ b/src/apps/external_competitions/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ExternalCompetitionsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'external_competitions' diff --git a/src/apps/external_competitions/fetch_sync.py b/src/apps/external_competitions/fetch_sync.py new file mode 100644 index 000000000..f757ec9e5 --- /dev/null +++ b/src/apps/external_competitions/fetch_sync.py @@ -0,0 +1,92 @@ +import logging + +from django.conf import settings +from django.utils.timezone import now + +from celery_config import app +from external_competitions.fetchers import FETCHERS +from external_competitions.fetchers.exceptions import PartialFetchError +from external_competitions.models import ExternalCompetition, ExternalFetchLog, ExternalPlatform + +logger = logging.getLogger(__name__) + + +@app.task(queue="site-worker") +def fetch_external_competitions(): + if not settings.EXTERNAL_COMPETITIONS_ENABLED: + logger.info("External competitions feature is disabled, skipping fetch") + return + + logger.info("External competitions fetch started") + + platforms = ExternalPlatform.objects.filter(is_active=True) + if not platforms: + logger.info("No active external platforms to fetch") + return + + for platform in platforms: + sync_platform(platform) + + logger.info("External competitions fetch ended") + + +def sync_platform(platform): + logger.info(f"Fetching competitions for platform '{platform.name}'") + log = ExternalFetchLog.objects.create(platform=platform) + + try: + fetcher = FETCHERS.get(platform.platform_type) + if fetcher is None: + raise ValueError(f"No fetcher implemented for platform type '{platform.platform_type}'") + + partial_error = None + try: + fetched_competitions = fetcher(platform) + except PartialFetchError as e: + fetched_competitions = e.competitions + partial_error = str(e.original_exception) + + fetched_urls = set() + new_count = 0 + updated_count = 0 + for competition_data in fetched_competitions: + competition_url = competition_data['competition_url'] + fetched_urls.add(competition_url) + defaults = {key: value for key, value in competition_data.items() if key != 'competition_url'} + defaults['platform'] = platform + _, created = ExternalCompetition.objects.update_or_create( + competition_url=competition_url, + defaults=defaults, + ) + if created: + new_count += 1 + else: + updated_count += 1 + + if partial_error: + # We don't have the full picture of what's currently live on the + # platform, so we can't tell which existing rows are actually stale - + # skip the delete step rather than risk dropping valid competitions + # from the pages we didn't get to. + deleted_count = 0 + else: + # Diff-based sync: anything for this platform that wasn't in this fetch is gone from the source, so drop it + deleted_count, _ = ExternalCompetition.objects.filter(platform=platform).exclude( + competition_url__in=fetched_urls + ).delete() + + log.status = ExternalFetchLog.STATUS_PARTIAL_SUCCESS if partial_error else ExternalFetchLog.STATUS_SUCCESS + log.total_fetched = len(fetched_urls) + log.new_count = new_count + log.updated_count = updated_count + log.deleted_count = deleted_count + log.error_message = partial_error or '' + log.finished_at = now() + log.save() + logger.info(f"Finished fetching platform '{platform.name}'" + (" (partial success)" if partial_error else "")) + except Exception as e: + logger.exception(f"Failed to fetch competitions for platform '{platform.name}'") + log.status = ExternalFetchLog.STATUS_FAILURE + log.error_message = str(e) + log.finished_at = now() + log.save() diff --git a/src/apps/external_competitions/fetchers/__init__.py b/src/apps/external_competitions/fetchers/__init__.py new file mode 100644 index 000000000..998cee9a1 --- /dev/null +++ b/src/apps/external_competitions/fetchers/__init__.py @@ -0,0 +1,8 @@ +from external_competitions.fetchers.codabench_fetcher import fetch_codabench_competitions +from external_competitions.fetchers.codalab_fetcher import fetch_codalab_competitions +from external_competitions.models import ExternalPlatform + +FETCHERS = { + ExternalPlatform.PLATFORM_TYPE_CODABENCH: fetch_codabench_competitions, + ExternalPlatform.PLATFORM_TYPE_CODALAB: fetch_codalab_competitions, +} diff --git a/src/apps/external_competitions/fetchers/codabench_fetcher.py b/src/apps/external_competitions/fetchers/codabench_fetcher.py new file mode 100644 index 000000000..c55f160be --- /dev/null +++ b/src/apps/external_competitions/fetchers/codabench_fetcher.py @@ -0,0 +1,57 @@ +import time + +import requests + +from external_competitions.fetchers.exceptions import PartialFetchError + +REQUEST_TIMEOUT = 30 # seconds +MAX_PAGES = 100 # safety cap so a misbehaving/malicious platform can't loop us forever +PAGE_FETCH_DELAY = 10 # seconds - throttle between page requests so we don't hammer the platform + + +def fetch_codabench_competitions(platform): + """ + Fetch competitions from a Codabench instance's public competitions API + (`platform.competitions_fetch_url`, e.g. .../api/competitions/public/), following + DRF-style pagination (`next`/`results`), and normalize them to the fields + ExternalCompetition needs. + """ + competitions = [] + url = platform.competitions_fetch_url + + for _ in range(MAX_PAGES): + if not url: + break + + try: + response = requests.get(url, timeout=REQUEST_TIMEOUT) + # Raises HTTPError on a 4xx/5xx response, instead of silently continuing to + # parse an error page's body as JSON below. + response.raise_for_status() + data = response.json() + except Exception as e: + # If earlier pages already succeeded, hand those back instead of losing + # them - sync_platform() saves them as a partial success. A failure on + # the very first page has nothing to salvage, so it just propagates and + # is logged as a full FAILURE there. + if competitions: + raise PartialFetchError(competitions, e) from e + raise + + for item in data.get('results', []): + competitions.append({ + 'name': item.get('title', ''), + 'description': item.get('description') or '', + 'image_url': (item.get('logo') or '').split('?')[0], + 'organizer_name': item.get('owner_display_name') or item.get('created_by') or '', + 'competition_url': f"{platform.competition_base_url.rstrip('/')}/{item['id']}/", + 'competition_created_when': item.get('created_when'), + # TODO: Not available on this API yet - fill in once it exposes a start date + 'competition_started_when': None, + }) + + url = data.get('next') + if url: + time.sleep(PAGE_FETCH_DELAY) + + return competitions diff --git a/src/apps/external_competitions/fetchers/codalab_fetcher.py b/src/apps/external_competitions/fetchers/codalab_fetcher.py new file mode 100644 index 000000000..78368f908 --- /dev/null +++ b/src/apps/external_competitions/fetchers/codalab_fetcher.py @@ -0,0 +1,34 @@ +import requests + +REQUEST_TIMEOUT = 300 # seconds - the CodaLab list endpoint returns every competition in one +# unpaginated response (no `next`/`results` wrapper, no page/limit params), so this single +# request can be very large and slow. + + +def fetch_codalab_competitions(platform): + """ + Fetch competitions from a CodaLab instance's competitions API + (`platform.competitions_fetch_url`, e.g. .../api/competition/). Unlike Codabench, + this endpoint returns a single flat JSON array with no pagination and no + organizer display name (only a numeric `creator` user id), so `organizer_name` + is left blank here. It also has no creation-date field - only `start_date` + (mapped to competition_started_when) and `last_modified` - so + competition_created_when is left blank rather than mapped to something that + means a different thing. + """ + response = requests.get(platform.competitions_fetch_url, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + items = response.json() + + return [ + { + 'name': item.get('title', ''), + 'description': item.get('description') or '', + 'image_url': item.get('image') or '', + 'organizer_name': '', + 'competition_url': f"{platform.competition_base_url.rstrip('/')}/{item['id']}", + 'competition_created_when': None, + 'competition_started_when': item.get('start_date'), + } + for item in items + ] diff --git a/src/apps/external_competitions/fetchers/exceptions.py b/src/apps/external_competitions/fetchers/exceptions.py new file mode 100644 index 000000000..df1902736 --- /dev/null +++ b/src/apps/external_competitions/fetchers/exceptions.py @@ -0,0 +1,12 @@ +class PartialFetchError(Exception): + """ + Raised by a fetcher that made some progress (e.g. fetched earlier pages of a + paginated response) before hitting an error. Carries whatever competitions + were already collected, so sync_platform can save that partial result instead + of discarding a partially-successful fetch entirely. + """ + + def __init__(self, competitions, original_exception): + self.competitions = competitions + self.original_exception = original_exception + super().__init__(str(original_exception)) diff --git a/src/apps/external_competitions/migrations/0001_initial.py b/src/apps/external_competitions/migrations/0001_initial.py new file mode 100644 index 000000000..3ba9478d2 --- /dev/null +++ b/src/apps/external_competitions/migrations/0001_initial.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.13 on 2026-08-21 14:26 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ExternalPlatform', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, unique=True)), + ('platform_type', models.CharField(choices=[('codabench', 'Codabench instance'), ('codalab', 'CodaLab instance')], max_length=32)), + ('competitions_fetch_url', models.URLField(help_text='API URL used to fetch the list of competitions from this platform')), + ('competition_base_url', models.URLField(help_text='Base URL used to build links back to individual competitions on this platform')), + ('is_active', models.BooleanField(default=True, help_text='Inactive platforms are skipped by the fetch task')), + ('created_when', models.DateTimeField(default=django.utils.timezone.now)), + ('updated_when', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='ExternalFetchLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('started_at', models.DateTimeField(default=django.utils.timezone.now)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('RUNNING', 'Running'), ('SUCCESS', 'Success'), ('PARTIAL_SUCCESS', 'Partial success'), ('FAILURE', 'Failure')], default='RUNNING', max_length=16)), + ('total_fetched', models.PositiveIntegerField(default=0)), + ('new_count', models.PositiveIntegerField(default=0)), + ('updated_count', models.PositiveIntegerField(default=0)), + ('deleted_count', models.PositiveIntegerField(default=0)), + ('error_message', models.TextField(blank=True, default='')), + ('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fetch_logs', to='external_competitions.externalplatform')), + ], + options={ + 'ordering': ['-started_at'], + }, + ), + migrations.CreateModel( + name='ExternalCompetition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='')), + ('image_url', models.URLField(blank=True, default='', max_length=1000)), + ('organizer_name', models.CharField(blank=True, default='', max_length=255)), + ('competition_url', models.URLField(help_text='Link to the competition on its source platform', unique=True)), + ('competition_created_when', models.DateTimeField(blank=True, help_text='When the competition was created on its source platform', null=True)), + ('competition_started_when', models.DateTimeField(blank=True, help_text='When the competition starts/started on its source platform', null=True)), + ('created_when', models.DateTimeField(default=django.utils.timezone.now)), + ('updated_when', models.DateTimeField(auto_now=True)), + ('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competitions', to='external_competitions.externalplatform')), + ], + ), + ] diff --git a/src/apps/external_competitions/migrations/__init__.py b/src/apps/external_competitions/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/external_competitions/models.py b/src/apps/external_competitions/models.py new file mode 100644 index 000000000..994585484 --- /dev/null +++ b/src/apps/external_competitions/models.py @@ -0,0 +1,71 @@ +from django.db import models +from django.utils.timezone import now + + +class ExternalPlatform(models.Model): + PLATFORM_TYPE_CODABENCH = 'codabench' + PLATFORM_TYPE_CODALAB = 'codalab' + PLATFORM_TYPE_CHOICES = ( + (PLATFORM_TYPE_CODABENCH, 'Codabench instance'), + (PLATFORM_TYPE_CODALAB, 'CodaLab instance'), + ) + + name = models.CharField(max_length=128, unique=True) + platform_type = models.CharField(max_length=32, choices=PLATFORM_TYPE_CHOICES) + competitions_fetch_url = models.URLField(help_text="API URL used to fetch the list of competitions from this platform") + competition_base_url = models.URLField(help_text="Base URL used to build links back to individual competitions on this platform") + is_active = models.BooleanField(default=True, help_text="Inactive platforms are skipped by the fetch task") + created_when = models.DateTimeField(default=now) + updated_when = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + +class ExternalCompetition(models.Model): + platform = models.ForeignKey(ExternalPlatform, on_delete=models.CASCADE, related_name='competitions') + name = models.CharField(max_length=255) + description = models.TextField(blank=True, default='') + image_url = models.URLField(max_length=1000, blank=True, default='') + organizer_name = models.CharField(max_length=255, blank=True, default='') + competition_url = models.URLField(unique=True, help_text="Link to the competition on its source platform") + competition_created_when = models.DateTimeField( + null=True, blank=True, help_text="When the competition was created on its source platform" + ) + competition_started_when = models.DateTimeField( + null=True, blank=True, help_text="When the competition starts/started on its source platform" + ) + created_when = models.DateTimeField(default=now) + updated_when = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.name} ({self.platform.name})" + + +class ExternalFetchLog(models.Model): + STATUS_RUNNING = 'RUNNING' + STATUS_SUCCESS = 'SUCCESS' + STATUS_PARTIAL_SUCCESS = 'PARTIAL_SUCCESS' + STATUS_FAILURE = 'FAILURE' + STATUS_CHOICES = ( + (STATUS_RUNNING, 'Running'), + (STATUS_SUCCESS, 'Success'), + (STATUS_PARTIAL_SUCCESS, 'Partial success'), + (STATUS_FAILURE, 'Failure'), + ) + + platform = models.ForeignKey(ExternalPlatform, on_delete=models.CASCADE, related_name='fetch_logs') + started_at = models.DateTimeField(default=now) + finished_at = models.DateTimeField(null=True, blank=True) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_RUNNING) + total_fetched = models.PositiveIntegerField(default=0) + new_count = models.PositiveIntegerField(default=0) + updated_count = models.PositiveIntegerField(default=0) + deleted_count = models.PositiveIntegerField(default=0) + error_message = models.TextField(blank=True, default='') + + class Meta: + ordering = ['-started_at'] + + def __str__(self): + return f"{self.platform.name} @ {self.started_at:%Y-%m-%d %H:%M} — {self.status}" diff --git a/src/apps/external_competitions/tests/__init__.py b/src/apps/external_competitions/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/external_competitions/tests/test_fetch_sync.py b/src/apps/external_competitions/tests/test_fetch_sync.py new file mode 100644 index 000000000..89c96ad67 --- /dev/null +++ b/src/apps/external_competitions/tests/test_fetch_sync.py @@ -0,0 +1,230 @@ +from unittest import mock + +from django.test import TestCase, override_settings + +from external_competitions.fetch_sync import fetch_external_competitions, sync_platform +from external_competitions.fetchers.exceptions import PartialFetchError +from external_competitions.models import ExternalCompetition, ExternalFetchLog, ExternalPlatform +from factories import ExternalCompetitionFactory, ExternalPlatformFactory + + +def _competition_data(n, **overrides): + """ + Builds one fake fetched-competition dict, in the shape a fetcher would hand + back to sync_platform. `n` makes name/competition_url unique per call (so + e.g. _competition_data(1) and _competition_data(2) don't collide), and any + keyword args in **overrides replace just those fields, e.g. + _competition_data(1, name='New Name') keeps every other default as-is. + """ + data = { + 'name': f'Competition {n}', + 'description': '', + 'image_url': '', + 'organizer_name': '', + 'competition_url': f'https://example.org/competitions/{n}/', + 'competition_created_when': None, + 'competition_started_when': None, + } + data.update(overrides) + return data + + +class SyncPlatformTests(TestCase): + def setUp(self): + self.platform = ExternalPlatformFactory(platform_type=ExternalPlatform.PLATFORM_TYPE_CODABENCH) + + def _sync_with(self, fetched_data): + """ + Runs sync_platform() for self.platform as if a fetcher had returned + fetched_data, without any real HTTP call. Swaps the real FETCHERS entry + for this platform's type with a fake fetcher that just returns + fetched_data, only for the duration of the `with` block. + """ + fetcher = mock.Mock(return_value=fetched_data) + with mock.patch.dict('external_competitions.fetch_sync.FETCHERS', {self.platform.platform_type: fetcher}): + sync_platform(self.platform) + return fetcher + + def test_creates_new_competitions_on_first_fetch(self): + """ + Syncs a platform with no existing competitions and checks a row is + created for each fetched item, logged as a success with new_count set. + """ + self._sync_with([_competition_data(1), _competition_data(2)]) + + self.assertEqual(ExternalCompetition.objects.filter(platform=self.platform).count(), 2) + log = ExternalFetchLog.objects.get(platform=self.platform) + self.assertEqual(log.status, ExternalFetchLog.STATUS_SUCCESS) + self.assertEqual(log.total_fetched, 2) + self.assertEqual(log.new_count, 2) + self.assertEqual(log.updated_count, 0) + self.assertEqual(log.deleted_count, 0) + self.assertIsNotNone(log.finished_at) + + def test_refetching_identical_data_updates_not_recreates(self): + """ + Syncs the same platform twice with unchanged data and checks the second + sync updates the existing rows in place (same ids) instead of duplicating them. + """ + self._sync_with([_competition_data(1), _competition_data(2)]) + ids_before = set(ExternalCompetition.objects.filter(platform=self.platform).values_list('id', flat=True)) + + self._sync_with([_competition_data(1), _competition_data(2)]) + + ids_after = set(ExternalCompetition.objects.filter(platform=self.platform).values_list('id', flat=True)) + self.assertEqual(ids_before, ids_after) + log = ExternalFetchLog.objects.filter(platform=self.platform).latest('id') + self.assertEqual(log.new_count, 0) + self.assertEqual(log.updated_count, 2) + self.assertEqual(log.deleted_count, 0) + + def test_missing_competition_is_deleted(self): + """ + Syncs a platform, then re-syncs with one of the two competitions no + longer in the fetched data, and checks that missing one gets deleted. + """ + self._sync_with([_competition_data(1), _competition_data(2)]) + self._sync_with([_competition_data(1)]) + + self.assertEqual(ExternalCompetition.objects.filter(platform=self.platform).count(), 1) + log = ExternalFetchLog.objects.filter(platform=self.platform).latest('id') + self.assertEqual(log.new_count, 0) + self.assertEqual(log.updated_count, 1) + self.assertEqual(log.deleted_count, 1) + + def test_changed_field_updates_existing_row(self): + """ + Re-syncs the same competition_url with a changed name and checks the + existing row's name is updated in place, without creating a duplicate. + """ + self._sync_with([_competition_data(1, name='Old Name')]) + + self._sync_with([_competition_data(1, name='New Name')]) + + self.assertEqual(ExternalCompetition.objects.filter(platform=self.platform).count(), 1) + competition = ExternalCompetition.objects.get(platform=self.platform) + self.assertEqual(competition.name, 'New Name') + log = ExternalFetchLog.objects.filter(platform=self.platform).latest('id') + self.assertEqual(log.updated_count, 1) + + def test_unknown_platform_type_fails_gracefully(self): + """ + Syncs a platform whose type has no matching entry in FETCHERS and checks + it's logged as a failure with a "No fetcher implemented" message, no crash. + """ + self.platform.platform_type = 'not_a_real_type' + self.platform.save() + + sync_platform(self.platform) + + log = ExternalFetchLog.objects.get(platform=self.platform) + self.assertEqual(log.status, ExternalFetchLog.STATUS_FAILURE) + self.assertIn('No fetcher implemented', log.error_message) + + def test_fetcher_exception_fails_gracefully_without_partial_writes(self): + """ + Makes the fetcher raise a plain exception and checks the sync is logged + as a failure while existing competitions for that platform are left untouched. + """ + ExternalCompetitionFactory(platform=self.platform, competition_url='https://example.org/competitions/1/') + count_before = ExternalCompetition.objects.filter(platform=self.platform).count() + + fetcher = mock.Mock(side_effect=ConnectionError('unreachable')) + with mock.patch.dict('external_competitions.fetch_sync.FETCHERS', {self.platform.platform_type: fetcher}): + sync_platform(self.platform) + + self.assertEqual(ExternalCompetition.objects.filter(platform=self.platform).count(), count_before) + log = ExternalFetchLog.objects.get(platform=self.platform) + self.assertEqual(log.status, ExternalFetchLog.STATUS_FAILURE) + self.assertIn('unreachable', log.error_message) + + def test_partial_fetch_error_saves_partial_data_and_skips_delete(self): + """ + Makes the fetcher raise PartialFetchError and checks the partial data is + saved, an unrelated existing competition is kept (not wrongly deleted), + and the log is marked PARTIAL_SUCCESS with the original error message. + """ + # Pre-existing row for this platform, so we can check it survives a partial sync. + stale = ExternalCompetitionFactory( + platform=self.platform, competition_url='https://example.org/competitions/stale/' + ) + + # The fetcher's fetched data won't include `stale` above. On a full success + # that would delete `stale` as no-longer-listed, but a partial one must not. + fetcher = mock.Mock(side_effect=PartialFetchError([_competition_data(1)], ConnectionError('Connection refused'))) + with mock.patch.dict('external_competitions.fetch_sync.FETCHERS', {self.platform.platform_type: fetcher}): + sync_platform(self.platform) + + self.assertTrue(ExternalCompetition.objects.filter(platform=self.platform, id=stale.id).exists()) + self.assertTrue( + ExternalCompetition.objects.filter( + platform=self.platform, competition_url='https://example.org/competitions/1/' + ).exists() + ) + log = ExternalFetchLog.objects.get(platform=self.platform) + self.assertEqual(log.status, ExternalFetchLog.STATUS_PARTIAL_SUCCESS) + self.assertEqual(log.deleted_count, 0) + self.assertEqual(log.error_message, 'Connection refused') + + +class FetchExternalCompetitionsTests(TestCase): + @override_settings(EXTERNAL_COMPETITIONS_ENABLED=False) + @mock.patch('external_competitions.fetch_sync.sync_platform') + def test_does_nothing_when_disabled(self, mock_sync_platform): + """ + Runs the task with the feature flag off and checks sync_platform is never + called, even though an active platform exists. + """ + ExternalPlatformFactory(is_active=True) + + fetch_external_competitions() + + mock_sync_platform.assert_not_called() + + @override_settings(EXTERNAL_COMPETITIONS_ENABLED=True) + @mock.patch('external_competitions.fetch_sync.sync_platform') + def test_does_nothing_when_no_platforms(self, mock_sync_platform): + """ + Runs the task with the flag on but no ExternalPlatform rows at all, and + checks it exits quietly without calling sync_platform or erroring. + """ + fetch_external_competitions() + + mock_sync_platform.assert_not_called() + + @override_settings(EXTERNAL_COMPETITIONS_ENABLED=True) + @mock.patch('external_competitions.fetch_sync.sync_platform') + def test_only_fetches_active_platforms(self, mock_sync_platform): + """ + Creates one active and one inactive platform and checks sync_platform is + called only for the active one. + """ + active = ExternalPlatformFactory(is_active=True) + ExternalPlatformFactory(is_active=False) + + fetch_external_competitions() + + mock_sync_platform.assert_called_once_with(active) + + @override_settings(EXTERNAL_COMPETITIONS_ENABLED=True) + def test_one_platform_failing_does_not_stop_the_others(self): + """ + Runs two platforms where one's fetcher raises an exception, and checks + the other platform still syncs successfully instead of the loop aborting. + """ + failing_platform = ExternalPlatformFactory(platform_type=ExternalPlatform.PLATFORM_TYPE_CODABENCH) + healthy_platform = ExternalPlatformFactory(platform_type=ExternalPlatform.PLATFORM_TYPE_CODALAB) + + failing_fetcher = mock.Mock(side_effect=ConnectionError('unreachable')) + healthy_fetcher = mock.Mock(return_value=[_competition_data(1)]) + fetchers = { + ExternalPlatform.PLATFORM_TYPE_CODABENCH: failing_fetcher, + ExternalPlatform.PLATFORM_TYPE_CODALAB: healthy_fetcher, + } + with mock.patch.dict('external_competitions.fetch_sync.FETCHERS', fetchers): + fetch_external_competitions() + + failing_log = ExternalFetchLog.objects.get(platform=failing_platform) + healthy_log = ExternalFetchLog.objects.get(platform=healthy_platform) + self.assertEqual(failing_log.status, ExternalFetchLog.STATUS_FAILURE) + self.assertEqual(healthy_log.status, ExternalFetchLog.STATUS_SUCCESS) diff --git a/src/apps/external_competitions/tests/test_fetchers.py b/src/apps/external_competitions/tests/test_fetchers.py new file mode 100644 index 000000000..fda7c5f16 --- /dev/null +++ b/src/apps/external_competitions/tests/test_fetchers.py @@ -0,0 +1,266 @@ +from unittest import mock + +from django.test import TestCase +from requests.exceptions import HTTPError + +from external_competitions.fetchers.codabench_fetcher import fetch_codabench_competitions +from external_competitions.fetchers.codalab_fetcher import fetch_codalab_competitions +from external_competitions.fetchers.exceptions import PartialFetchError +from factories import ExternalPlatformFactory + + +def _mock_response(json_data, raise_for_status=None): + """ + Builds a fake requests.Response standing in for `requests.get(...)`'s return + value, so tests can control its .json() body without any real HTTP call. + Pass an exception as raise_for_status to simulate an HTTP error response, + e.g. HTTPError('500 Server Error') for a failed request - .raise_for_status() + will then raise that exception, just like the real requests library does. + """ + response = mock.Mock() + response.json.return_value = json_data + if raise_for_status is not None: + response.raise_for_status.side_effect = raise_for_status + return response + + +class FetchCodabenchCompetitionsTests(TestCase): + def setUp(self): + self.platform = ExternalPlatformFactory( + competitions_fetch_url='https://codabench.example.org/api/competitions/public/', + competition_base_url='https://codabench.example.org/competitions', + ) + + @mock.patch('external_competitions.fetchers.codabench_fetcher.requests.get') + def test_single_page(self, mock_get): + """ + Fetches a single unpaginated page and checks each item is mapped to the + right fields, including the competition_url built from the base url and id. + """ + mock_get.return_value = _mock_response({ + 'next': None, + 'results': [{ + 'id': 42, + 'title': 'Iris', + 'description': 'The well known Iris dataset', + 'logo': 'https://codabench.example.org/logo.png', + 'owner_display_name': 'Jane Doe', + 'created_when': '2026-01-01T00:00:00Z', + }], + }) + + result = fetch_codabench_competitions(self.platform) + + self.assertEqual(result, [{ + 'name': 'Iris', + 'description': 'The well known Iris dataset', + 'image_url': 'https://codabench.example.org/logo.png', + 'organizer_name': 'Jane Doe', + 'competition_url': 'https://codabench.example.org/competitions/42/', + 'competition_created_when': '2026-01-01T00:00:00Z', + 'competition_started_when': None, + }]) + # Separately from checking the output above, confirm requests.get was actually + # called with the right URL, and only once. mock.ANY matches any value - we + # don't care about the exact timeout, just that one was passed. + mock_get.assert_called_once_with(self.platform.competitions_fetch_url, timeout=mock.ANY) + + @mock.patch('external_competitions.fetchers.codabench_fetcher.requests.get') + def test_strips_query_params_from_logo_url(self, mock_get): + """ + Fetches an item whose logo url has query params (like a presigned MinIO + link) and checks everything after the "?" is stripped from image_url. + """ + mock_get.return_value = _mock_response({ + 'next': None, + 'results': [{ + 'id': 1, + 'title': 'Comp', + 'logo': 'https://minio.example.org/logo.png?X-Amz-Signature=abc&X-Amz-Expires=3600', + }], + }) + + result = fetch_codabench_competitions(self.platform) + + self.assertEqual(result[0]['image_url'], 'https://minio.example.org/logo.png') + + # Two things get mocked here because the function under test touches two real + # dependencies when paginating: requests.get (so we control the fake responses) + # and time.sleep (so the test doesn't actually wait 10 real seconds). Decorators + # apply bottom-up but their mocks are injected top-down, so the parameter order + # below matches: requests.get (closest decorator) -> mock_get, time.sleep -> mock_sleep. + @mock.patch('external_competitions.fetchers.codabench_fetcher.time.sleep') + @mock.patch('external_competitions.fetchers.codabench_fetcher.requests.get') + def test_follows_pagination_and_sleeps_between_pages(self, mock_get, mock_sleep): + """ + Fetches two pages linked by "next" and checks both pages' results are + combined into one list, with exactly one sleep call between the requests. + """ + page_two_url = 'https://codabench.example.org/api/competitions/public/?page=2' + # side_effect as a list makes the mock return a different value on each + # successive call: the 1st call to requests.get(...) returns the page 1 + # response, the 2nd call returns page 2. (return_value can only ever give + # back one fixed answer, which won't work once there's more than one call.) + mock_get.side_effect = [ + _mock_response({ + 'next': page_two_url, + 'results': [{'id': 1, 'title': 'Comp 1'}], + }), + _mock_response({ + 'next': None, + 'results': [{'id': 2, 'title': 'Comp 2'}], + }), + ] + + result = fetch_codabench_competitions(self.platform) + + # result is one flat list from the single function call - seeing both + # 'Comp 1' (page 1) and 'Comp 2' (page 2) here proves they were combined. + self.assertEqual([c['name'] for c in result], ['Comp 1', 'Comp 2']) + # call_args_list is the full history of calls made to the mock, in order. + # This proves the function followed the "next" link from page 1's response + # instead of, say, re-requesting the same URL or ignoring pagination. + self.assertEqual(mock_get.call_args_list, [ + mock.call(self.platform.competitions_fetch_url, timeout=mock.ANY), + mock.call(page_two_url, timeout=mock.ANY), + ]) + mock_sleep.assert_called_once() + + @mock.patch('external_competitions.fetchers.codabench_fetcher.time.sleep') + @mock.patch('external_competitions.fetchers.codabench_fetcher.requests.get') + def test_does_not_sleep_after_last_page(self, mock_get, mock_sleep): + """ + Fetches a single page with no "next" link and checks sleep is never + called, since there's no next request to throttle before. + """ + mock_get.return_value = _mock_response({'next': None, 'results': []}) + + fetch_codabench_competitions(self.platform) + + mock_sleep.assert_not_called() + + @mock.patch('external_competitions.fetchers.codabench_fetcher.time.sleep') + @mock.patch('external_competitions.fetchers.codabench_fetcher.requests.get') + def test_stops_at_max_pages(self, mock_get, mock_sleep): + """ + Simulates a "next" link that never runs out (e.g. a broken or malicious + platform) and checks the loop stops after MAX_PAGES requests, not forever. + """ + # Every page "returns" the same response, whose 'next' never becomes falsy - + # so nothing here ever ends the loop naturally. The only thing that can stop + # it is the fetcher's own MAX_PAGES cap. + mock_get.return_value = _mock_response({ + 'next': 'https://codabench.example.org/api/competitions/public/?page=999', + 'results': [{'id': 1, 'title': 'Comp'}], + }) + + result = fetch_codabench_competitions(self.platform) + + self.assertEqual(mock_get.call_count, 100) + self.assertEqual(len(result), 100) + # 'next' never goes falsy, so sleep is called after every page too - 100 times. + self.assertEqual(mock_sleep.call_count, 100) + + @mock.patch('external_competitions.fetchers.codabench_fetcher.requests.get') + def test_http_error_propagates(self, mock_get): + """ + Fails the very first page's request and checks the original HTTPError + propagates as-is, since there's no earlier page data to salvage. + """ + mock_get.return_value = _mock_response({}, raise_for_status=HTTPError('500 Server Error')) + + with self.assertRaises(HTTPError): + fetch_codabench_competitions(self.platform) + + @mock.patch('external_competitions.fetchers.codabench_fetcher.time.sleep') + @mock.patch('external_competitions.fetchers.codabench_fetcher.requests.get') + def test_partial_fetch_error_when_later_page_fails(self, mock_get, mock_sleep): + """ + Succeeds on page 1 then fails on page 2, and checks a PartialFetchError is + raised carrying page 1's results plus the original error that caused it. + """ + page_two_url = 'https://codabench.example.org/api/competitions/public/?page=2' + error = HTTPError('500 Server Error') + # Same side_effect-list trick as the pagination test: 1st call succeeds + # (page 1), 2nd call's raise_for_status() raises our error (page 2 fails). + mock_get.side_effect = [ + _mock_response({ + 'next': page_two_url, + 'results': [{'id': 1, 'title': 'Comp 1'}], + }), + _mock_response({}, raise_for_status=error), + ] + + # assertRaises as a context manager gives us `cm.exception` afterwards - the + # actual exception instance that was raised, so we can inspect its attributes. + with self.assertRaises(PartialFetchError) as cm: + fetch_codabench_competitions(self.platform) + + self.assertEqual([c['name'] for c in cm.exception.competitions], ['Comp 1']) + self.assertIs(cm.exception.original_exception, error) + + @mock.patch('external_competitions.fetchers.codabench_fetcher.requests.get') + def test_empty_results(self, mock_get): + """ + Fetches a page with an empty "results" list and checks the function + returns an empty list instead of erroring. + """ + mock_get.return_value = _mock_response({'next': None, 'results': []}) + + self.assertEqual(fetch_codabench_competitions(self.platform), []) + + +class FetchCodalabCompetitionsTests(TestCase): + def setUp(self): + self.platform = ExternalPlatformFactory( + competitions_fetch_url='https://codalab.example.org/api/competition/', + competition_base_url='https://codalab.example.org/competitions', + ) + + @mock.patch('external_competitions.fetchers.codalab_fetcher.requests.get') + def test_flat_list_response(self, mock_get): + """ + Fetches CodaLab's flat (non-paginated) list response and checks each item + is mapped to the right fields, with organizer_name and created_when unset. + """ + mock_get.return_value = _mock_response([{ + 'id': 7, + 'title': 'Vision Challenge', + 'description': 'A challenge', + 'image': 'https://codalab.example.org/logo.png', + 'start_date': '2026-02-01T00:00:00Z', + }]) + + result = fetch_codalab_competitions(self.platform) + + self.assertEqual(result, [{ + 'name': 'Vision Challenge', + 'description': 'A challenge', + 'image_url': 'https://codalab.example.org/logo.png', + 'organizer_name': '', + 'competition_url': 'https://codalab.example.org/competitions/7', + 'competition_created_when': None, + 'competition_started_when': '2026-02-01T00:00:00Z', + }]) + mock_get.assert_called_once_with(self.platform.competitions_fetch_url, timeout=mock.ANY) + + @mock.patch('external_competitions.fetchers.codalab_fetcher.requests.get') + def test_empty_list(self, mock_get): + """ + Fetches an empty list response and checks the function returns an empty + list instead of erroring. + """ + mock_get.return_value = _mock_response([]) + + self.assertEqual(fetch_codalab_competitions(self.platform), []) + + @mock.patch('external_competitions.fetchers.codalab_fetcher.requests.get') + def test_http_error_propagates(self, mock_get): + """ + Fails the request and checks the original HTTPError propagates as-is, + since CodaLab's fetch is a single request with nothing to salvage. + """ + mock_get.return_value = _mock_response([], raise_for_status=HTTPError('500 Server Error')) + + with self.assertRaises(HTTPError): + fetch_codalab_competitions(self.platform) diff --git a/src/apps/external_competitions/urls.py b/src/apps/external_competitions/urls.py new file mode 100644 index 000000000..d95b6eb87 --- /dev/null +++ b/src/apps/external_competitions/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from external_competitions import views + + +app_name = 'external_competitions' + +urlpatterns = [ + path('', views.ExternalCompetitionsPublic.as_view(), name='public'), +] diff --git a/src/apps/external_competitions/views.py b/src/apps/external_competitions/views.py new file mode 100644 index 000000000..71da8c236 --- /dev/null +++ b/src/apps/external_competitions/views.py @@ -0,0 +1,5 @@ +from django.views.generic import TemplateView + + +class ExternalCompetitionsPublic(TemplateView): + template_name = 'external_competitions/public.html' diff --git a/src/factories.py b/src/factories.py index a149db4d0..2ff6ecbd0 100644 --- a/src/factories.py +++ b/src/factories.py @@ -9,6 +9,7 @@ from competitions.models import Competition, Phase, Submission, CompetitionParticipant, PhaseTaskInstance from datasets.models import Data +from external_competitions.models import ExternalPlatform, ExternalCompetition from leaderboards.models import Leaderboard, Column, SubmissionScore from profiles.models import User, Organization from tasks.models import Task, Solution @@ -227,3 +228,22 @@ class Meta: name = factory.Faker('word') email = factory.Faker('email') + + +class ExternalPlatformFactory(DjangoModelFactory): + class Meta: + model = ExternalPlatform + + name = factory.Sequence(lambda n: f'External Platform {n}') + platform_type = ExternalPlatform.PLATFORM_TYPE_CODABENCH + competitions_fetch_url = factory.Faker('url') + competition_base_url = factory.Faker('url') + + +class ExternalCompetitionFactory(DjangoModelFactory): + class Meta: + model = ExternalCompetition + + platform = factory.SubFactory(ExternalPlatformFactory) + name = factory.Sequence(lambda n: f'External Competition {n}') + competition_url = factory.Sequence(lambda n: f'https://example.org/competitions/{n}/') diff --git a/src/settings/base.py b/src/settings/base.py index 3f39a2448..bf0d704ff 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -78,6 +78,7 @@ 'forums', 'announcements', 'oidc_configurations', + 'external_competitions', ) INSTALLED_APPS = THIRD_PARTY_APPS + OUR_APPS @@ -584,3 +585,16 @@ def setup_celery_logging(**kwargs): # ============================================================================= ENABLE_SIGN_UP = os.environ.get('ENABLE_SIGN_UP', 'True').lower() == 'true' ENABLE_SIGN_IN = os.environ.get('ENABLE_SIGN_IN', 'True').lower() == 'true' + + +# ============================================================================= +# Enable or disable the External Competitions feature (button, page, API, +# and the daily fetch task). Off by default - intended for the main instance only. +# ============================================================================= +EXTERNAL_COMPETITIONS_ENABLED = os.environ.get('EXTERNAL_COMPETITIONS_ENABLED', 'False').lower() == 'true' + +if EXTERNAL_COMPETITIONS_ENABLED: + CELERY_BEAT_SCHEDULE['fetch_external_competitions'] = { + 'task': 'external_competitions.fetch_sync.fetch_external_competitions', + 'schedule': timedelta(days=1), + } diff --git a/src/static/js/ours/client.js b/src/static/js/ours/client.js index fa169c5a8..3336913a1 100644 --- a/src/static/js/ours/client.js +++ b/src/static/js/ours/client.js @@ -404,4 +404,13 @@ CODALAB.api = { request_delete_account: (data) => { return CODALAB.api.request('DELETE', `${URLS.API}delete_account/`, data) }, + /*--------------------------------------------------------------------- + External Competitions + ---------------------------------------------------------------------*/ + get_external_competitions: function (query) { + return CODALAB.api.request('GET', URLS.API + "external_competitions/", query) + }, + get_external_competition_platforms: function () { + return CODALAB.api.request('GET', URLS.API + "external_competitions/platforms/") + }, } diff --git a/src/static/riot/competitions/public-list.tag b/src/static/riot/competitions/public-list.tag index cc9da4975..1000c942b 100644 --- a/src/static/riot/competitions/public-list.tag +++ b/src/static/riot/competitions/public-list.tag @@ -12,6 +12,12 @@ + +
+ Browse external competitions from other Codabench instances + External Competitions +
+
@@ -324,6 +330,18 @@ color #fff text-decoration none + .external-competitions-banner + display flex + align-items center + justify-content space-between + padding 10px 15px + margin-bottom 20px + background #dce8f0 + border 1px solid #a5b7c5 + border-radius 4px + font-size 16px + color #2d3f4d + .content-container display flex width 100% diff --git a/src/static/riot/external_competitions/external_competition_list.tag b/src/static/riot/external_competitions/external_competition_list.tag new file mode 100644 index 000000000..ff5583a7b --- /dev/null +++ b/src/static/riot/external_competitions/external_competition_list.tag @@ -0,0 +1,410 @@ + + + + +

+ These competitions are hosted on other platforms (other Codabench and CodaLab instances) + and are fetched here periodically. Codabench does not manage registration, submissions, or data for them - + click through to a competition to view or join it on its original platform. +

+ + +
+ + +
+ +

Filters

+ + +
+ +
+ +
+
+ + +
+ Platform + +
+ + +
+ +
+
+ + +
+ +
+
+
+ + + + +
+
+
No external competitions found
+ Try changing your filters or search term. +
+
+ + +
+ + + { current_page } of {Math.ceil(competitions.count/competitions.page_size)} + + +
+ +
+
+ + + + +
diff --git a/src/templates/base.html b/src/templates/base.html index cfd0db083..baec15c06 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -312,6 +312,8 @@

CodaBench

let urlParam = "?phase=" + phase_id return urlBase.slice(0, -1) + ".json" + urlParam }, + // External Competitions - empty string when the feature is disabled + EXTERNAL_COMPETITIONS_PUBLIC: "{% if EXTERNAL_COMPETITIONS_ENABLED %}{% url 'external_competitions:public' %}{% endif %}", // Forums FORUM: function (pk) { return "{% url "forums:forum_detail" forum_pk=0 %}".replace(0, pk) diff --git a/src/templates/external_competitions/public.html b/src/templates/external_competitions/public.html new file mode 100644 index 000000000..e5d158435 --- /dev/null +++ b/src/templates/external_competitions/public.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block title %}External Competitions - Codabench{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/src/urls.py b/src/urls.py index 88013d5a7..58fcfe830 100644 --- a/src/urls.py +++ b/src/urls.py @@ -33,6 +33,11 @@ ] +if settings.EXTERNAL_COMPETITIONS_ENABLED: + urlpatterns += [ + path('competitions/external/', include('external_competitions.urls')), + ] + if settings.DEBUG: # Static files for local dev, so we don't have to collectstatic and such urlpatterns += staticfiles_urlpatterns() diff --git a/src/utils/context_processors.py b/src/utils/context_processors.py index 1d60fbc78..065758095 100644 --- a/src/utils/context_processors.py +++ b/src/utils/context_processors.py @@ -54,6 +54,7 @@ def common_settings(request): 'FLOWER_URL': f"http://{settings.DOMAIN_NAME}:{settings.FLOWER_PUBLIC_PORT}", 'ENABLE_SIGN_UP': settings.ENABLE_SIGN_UP, 'ENABLE_SIGN_IN': settings.ENABLE_SIGN_IN, + 'EXTERNAL_COMPETITIONS_ENABLED': settings.EXTERNAL_COMPETITIONS_ENABLED, 'VERSION_INFO': version_info, 'HOME_PAGE_COUNTERS_INFO': home_page_counters_info, 'DOMAIN_NAME': settings.DOMAIN_NAME,