diff --git a/.github/workflows/release-version-update.yml b/.github/workflows/release-version-update.yml deleted file mode 100644 index a5eebaabe..000000000 --- a/.github/workflows/release-version-update.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Update version.json and create PR - -# Trigger the github action when a new release is published -on: - release: - types: [published] - -jobs: - update-version: - runs-on: ubuntu-latest - - steps: - - # Step 1: Checkout repository - - name: Checkout repository - uses: actions/checkout@v3 - - # Step 2: Get latest release version information - - name: Get release information - id: get_release - run: | - response=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/releases/latest) - echo "$response" | jq '.tag_name, .name, .published_at, .html_url' | tee /tmp/release_info - echo "tag_name=$(echo "$response" | jq -r .tag_name)" >> $GITHUB_ENV - echo "name=$(echo "$response" | jq -r .name)" >> $GITHUB_ENV - echo "published_at=$(echo "$response" | jq -r .published_at)" >> $GITHUB_ENV - echo "html_url=$(echo "$response" | jq -r .html_url)" >> $GITHUB_ENV - - # Step 3: Update version.json file with latest information - - name: Update version.json - run: | - echo '{ - "tag_name": "${{ env.tag_name }}", - "release_name": "${{ env.name }}", - "published_at": "${{ env.published_at }}", - "html_url": "${{ env.html_url }}" - }' > version.json - - # Step 4: Configure a user to create a branch and then push changes - - name: Configure Git user - run: | - git config --global user.name "GitHub Actions" - git config --global user.email "actions@github.com" - - # Step 5: Create a new branch for version updates - - name: Create new branch - run: | - git checkout -b update-version-${{ env.tag_name }} - git add version.json - git commit -m "Update version.json for release ${{ env.tag_name }}" - - # Step 6: Push branch - - name: Push branch - run: | - git push origin update-version-${{ env.tag_name }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Step 7: Create a pull request from the pushed branch - - name: Create Pull Request - run: | - curl -X POST -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" -H "Accept: application/vnd.github.v3+json" \ - https://api.github.com/repos/${{ github.repository }}/pulls \ - -d '{ - "title": "[RELEASE ${{ env.tag_name }}] Update version.json for release ${{ env.tag_name }}", - "body": "This PR updates version.json with the latest release information: ${{ env.tag_name }}", - "head": "update-version-${{ env.tag_name }}", - "base": "develop" - }' diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 34ea6a303..4742e90f8 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -280,7 +280,7 @@ async def watch_detailed_results(self): else: logger.info(time.time() - start) if time.time() - start > expiration_seconds: - timeout_error_message = f"Detailed results not written to after {expiration_seconds} seconds, exiting!" + timeout_error_message = f"WARNING: Detailed results not written before the execution." logger.warning(timeout_error_message) await asyncio.sleep(5) file_path = self.get_detailed_results_file_path() diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index f617fa6b9..d35fed024 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -43,6 +43,8 @@ class Meta: 'max_submissions_per_person', 'auto_migrate_to_this_phase', 'hide_output', + 'hide_prediction_output', + 'hide_score_output', 'leaderboard', 'public_data', 'starting_kit', @@ -124,6 +126,8 @@ class Meta: 'max_submissions_per_person', 'auto_migrate_to_this_phase', 'hide_output', + 'hide_prediction_output', + 'hide_score_output', # no leaderboard 'public_data', 'starting_kit', diff --git a/src/apps/api/serializers/submissions.py b/src/apps/api/serializers/submissions.py index d14df9e11..6def18976 100644 --- a/src/apps/api/serializers/submissions.py +++ b/src/apps/api/serializers/submissions.py @@ -261,7 +261,7 @@ def get_data_file(self, instance): def get_prediction_result(self, instance): if instance.prediction_result.name: - if instance.phase.hide_output and not instance.phase.competition.user_has_admin_permission(self.context['request'].user): + if (instance.phase.hide_output or instance.phase.hide_prediction_output) and not instance.phase.competition.user_has_admin_permission(self.context['request'].user): return None return make_url_sassy(instance.prediction_result.name) @@ -271,7 +271,7 @@ def get_detailed_result(self, instance): def get_scoring_result(self, instance): if instance.scoring_result.name: - if instance.phase.hide_output and not instance.phase.competition.user_has_admin_permission(self.context['request'].user): + if (instance.phase.hide_output or instance.phase.hide_score_output) and not instance.phase.competition.user_has_admin_permission(self.context['request'].user): return None return make_url_sassy(instance.scoring_result.name) diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 4f53f7f31..f5d464761 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -275,6 +275,8 @@ def update(self, request, *args, **kwargs): name=phase["name"], description=phase["description"], hide_output=phase["hide_output"], + hide_prediction_output=phase["hide_prediction_output"], + hide_score_output=phase["hide_score_output"], competition=Competition.objects.get(id=data['id']) ) # Get phase id diff --git a/src/apps/api/views/submissions.py b/src/apps/api/views/submissions.py index 5bcbffc1a..afe14fb36 100644 --- a/src/apps/api/views/submissions.py +++ b/src/apps/api/views/submissions.py @@ -204,7 +204,7 @@ def soft_delete(self, request, pk): return Response({'error': 'You are not allowed to delete a leaderboard submission'}, status=status.HTTP_403_FORBIDDEN) # Check if submission is in running state - if submission.status not in [Submission.FAILED, Submission.FINISHED]: + if submission.status not in [Submission.FAILED, Submission.FINISHED, Submission.CANCELLED]: return Response({'error': 'You are not allowed to delete a running submission'}, status=status.HTTP_403_FORBIDDEN) # Check if submission is not already soft deleted @@ -351,16 +351,47 @@ def re_run_many_submissions(self, request): @action(detail=False, methods=['get']) def download_many(self, request): + """ + Download a ZIP containing several submissions. + """ pks = request.query_params.get('pks') if pks: pks = json.loads(pks) # Convert JSON string to list + else: + return Response({"error": "`pks` query parameter is required"}, status=400) + + # Get submissions + submissions = Submission.objects.filter(pk__in=pks).select_related( + "owner", + "phase__competition", + "phase__competition__created_by", + ).prefetch_related("phase__competition__collaborators") + if submissions.count() != len(pks): + return Response({"error": "One or more submission IDs are invalid"}, status=404) + + # Check permissions + if not request.user.is_authenticated: + raise PermissionDenied("You must be logged in to download submissions") + # Allow admins + if request.user.is_superuser or request.user.is_staff: + allowed = True + else: + # Build one Q object for "owner OR organizer" + organiser_q = ( + Q(phase__competition__created_by=request.user) | + Q(phase__competition__collaborators=request.user) + ) + # Submissions that violate the rule + disallowed = submissions.exclude(Q(owner=request.user) | organiser_q) + allowed = not disallowed.exists() + if not allowed: + raise PermissionDenied( + "You do not have permission to download one or more of the requested submissions" + ) - # Doing a local import here to avoid circular imports + # Download from competitions.tasks import stream_batch_download - - # in_memory_zip = stream_batch_download.apply_async((pks,)).get() in_memory_zip = stream_batch_download(pks) - response = StreamingHttpResponse(in_memory_zip, content_type='application/zip') response['Content-Disposition'] = 'attachment; filename="bulk_submissions.zip"' return response diff --git a/src/apps/competitions/migrations/0054_auto_20250321_1341.py b/src/apps/competitions/migrations/0054_auto_20250321_1341.py new file mode 100644 index 000000000..e5b7a6451 --- /dev/null +++ b/src/apps/competitions/migrations/0054_auto_20250321_1341.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-03-21 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0053_auto_20250218_1151'), + ] + + operations = [ + migrations.AlterField( + model_name='submissiondetails', + name='file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + ] diff --git a/src/apps/competitions/migrations/0056_merge_20250324_2128.py b/src/apps/competitions/migrations/0056_merge_20250324_2128.py new file mode 100644 index 000000000..16554ffa0 --- /dev/null +++ b/src/apps/competitions/migrations/0056_merge_20250324_2128.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.28 on 2025-03-24 21:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0054_auto_20250321_1341'), + ('competitions', '0055_merge_20250324_0650'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/migrations/0057_phase_hide_score_output.py b/src/apps/competitions/migrations/0057_phase_hide_score_output.py new file mode 100644 index 000000000..1f805e0f4 --- /dev/null +++ b/src/apps/competitions/migrations/0057_phase_hide_score_output.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-04-25 09:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0056_merge_20250324_2128'), + ] + + operations = [ + migrations.AddField( + model_name='phase', + name='hide_score_output', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/apps/competitions/migrations/0058_phase_hide_prediction_output.py b/src/apps/competitions/migrations/0058_phase_hide_prediction_output.py new file mode 100644 index 000000000..0c241ca2b --- /dev/null +++ b/src/apps/competitions/migrations/0058_phase_hide_prediction_output.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-05-14 19:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0057_phase_hide_score_output'), + ] + + operations = [ + migrations.AddField( + model_name='phase', + name='hide_prediction_output', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index 4d938895f..d8ce83dad 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -342,6 +342,8 @@ class Phase(ChaHubSaveMixin, models.Model): auto_migrate_to_this_phase = models.BooleanField(default=False) has_been_migrated = models.BooleanField(default=False) hide_output = models.BooleanField(default=False) + hide_prediction_output = models.BooleanField(default=False) + hide_score_output = models.BooleanField(default=False) has_max_submissions = models.BooleanField(default=True) max_submissions_per_day = models.PositiveIntegerField(default=5, null=True, blank=True) diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 87501be20..9b685d1d7 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -82,6 +82,8 @@ 'execution_time_limit', 'auto_migrate_to_this_phase', 'hide_output', + 'hide_prediction_output', + 'hide_score_output', ] PHASE_FILES = [ "input_data", diff --git a/src/apps/competitions/tests/unpacker_test_data.py b/src/apps/competitions/tests/unpacker_test_data.py index db69ec6dd..b2ee7e075 100644 --- a/src/apps/competitions/tests/unpacker_test_data.py +++ b/src/apps/competitions/tests/unpacker_test_data.py @@ -213,6 +213,9 @@ 'starting_kit': None, 'tasks': [0], 'status': 'Previous', + 'hide_output': False, + 'hide_prediction_output': False, + 'hide_score_output': False, }, { 'index': 1, @@ -230,14 +233,12 @@ 'tasks': [1], 'status': 'Current', 'is_final_phase': True, + 'hide_output': False, + 'hide_prediction_output': False, + 'hide_score_output': False, } ] -V2_SPECIFIC_PHASE_DATA = [ - # Tuples of (key, value) of data specific to v2 unpacker. - ('hide_output', False) -] - def get_phases(version): if version == 1: @@ -246,9 +247,6 @@ def get_phases(version): # Make a copy of the list so we aren't mutating the original phases object. May not be strictly necessary, # but if we ever write a test comparing v1 to v2 or something, this would avoid bugs. v2 = [{k: v for k, v in phase.items()} for phase in PHASES] - for phase in v2: - for key, value in V2_SPECIFIC_PHASE_DATA: - phase[key] = value return v2 diff --git a/src/apps/competitions/unpackers/v1.py b/src/apps/competitions/unpackers/v1.py index dc476a5c2..535e9e2d5 100644 --- a/src/apps/competitions/unpackers/v1.py +++ b/src/apps/competitions/unpackers/v1.py @@ -88,6 +88,9 @@ def _unpack_phases(self): 'max_submissions_per_day': phase.get('max_submissions_per_day', 5), 'max_submissions_per_person': phase.get('max_submissions', 100), 'auto_migrate_to_this_phase': phase.get('auto_migration', False), + 'hide_output': phase.get('hide_output', False), + 'hide_prediction_output': phase.get('hide_prediction_output', False), + 'hide_score_output': phase.get('hide_score_output', False), } execution_time_limit = phase.get('execution_time_limit') if execution_time_limit: diff --git a/src/apps/competitions/unpackers/v2.py b/src/apps/competitions/unpackers/v2.py index 85eddefcb..508479eba 100644 --- a/src/apps/competitions/unpackers/v2.py +++ b/src/apps/competitions/unpackers/v2.py @@ -198,6 +198,8 @@ def _unpack_phases(self): 'max_submissions_per_person': phase_data.get('max_submissions', 100), 'auto_migrate_to_this_phase': phase_data.get('auto_migrate_to_this_phase', False), 'hide_output': phase_data.get('hide_output', False), + 'hide_prediction_output': phase_data.get('hide_prediction_output', False), + 'hide_score_output': phase_data.get('hide_score_output', False), } try: new_phase['tasks'] = phase_data['tasks'] diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index e5a5f1f7c..74b746cc6 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -168,9 +168,9 @@ - + - diff --git a/src/static/riot/competitions/detail/submission_modal.tag b/src/static/riot/competitions/detail/submission_modal.tag index 55cb5dde4..0961c0b0b 100644 --- a/src/static/riot/competitions/detail/submission_modal.tag +++ b/src/static/riot/competitions/detail/submission_modal.tag @@ -22,12 +22,12 @@ - + Output from prediction step - + Output from scoring step diff --git a/src/static/riot/competitions/editor/_phases.tag b/src/static/riot/competitions/editor/_phases.tag index fd0e09cd5..a9c318c28 100644 --- a/src/static/riot/competitions/editor/_phases.tag +++ b/src/static/riot/competitions/editor/_phases.tag @@ -190,10 +190,31 @@
- +
+
+
+ + +
+
+
+
+ + +
+
@@ -420,22 +441,10 @@ } } - // Create a new options object for the start date calendar by combining 'date_options' - // with an additional option to link the end date calendar. This ensures that the start date - // cannot be after the selected end date. - var start_options = Object.assign({}, date_options, {endCalendar: self.refs.calendar_end_date}) - - // Similarly, create a new options object for the end date calendar, linking it to the start date. - // This ensures that the end date cannot be before the selected start date. - var end_options = Object.assign({}, date_options, {startCalendar: self.refs.calendar_start_date}) - - // Initialize the start date calendar using the options defined above, including the end date limitation. - $(self.refs.calendar_start_date).calendar(start_options) - - // Initialize the end date calendar using the options defined above, which includes the start date limitation. - $(self.refs.calendar_end_date).calendar(end_options) - - + // Create a new options object for the start date calendar using 'date_options' + $(self.refs.calendar_start_date).calendar(date_options) + // Create a new options object for the end date calendar using 'date_options' + $(self.refs.calendar_end_date).calendar(date_options) // Initialize the start time calendar with the defined options. // This will create a time picker for the 'start time' field. @@ -641,7 +650,6 @@ return new Date(Date.parse(date)) } - self.edit = function (index) { self.selected_phase_index = index var phase = self.phases[index] @@ -649,11 +657,12 @@ self.phase_public_data = [phase.public_data] self.phase_starting_kit = [phase.starting_kit] - self.update() set_form_data(phase, self.refs.form) $(self.refs.auto_migrate).prop('checked', _.get(phase, 'auto_migrate_to_this_phase', false)) self.refs.hide_output.checked = phase.hide_output + self.refs.hide_prediction_output.checked = phase.hide_prediction_output + self.refs.hide_score_output.checked = phase.hide_score_output // Setting description in markdown editor self.simple_markdown_editor.value(self.phases[index].description || '') @@ -795,6 +804,12 @@ data.end_time = "00:00" } data.end = self.formatDateTo_Y_m_d_T_H_M_S(data.end_date + " " + data.end_time) + + // Check: start date must not be after end date + if (new Date(data.start) > new Date(data.end)) { + toastr.error("End date cannot be earlier than the start date. Please choose a valid date range.") + return + } }else{ // end date is set to null if it is not selected because it is optional in the form data.end = null @@ -812,6 +827,8 @@ } data.auto_migrate_to_this_phase = $(self.refs.auto_migrate).prop('checked') data.hide_output = self.refs.hide_output.checked + data.hide_prediction_output = self.refs.hide_prediction_output.checked + data.hide_score_output = self.refs.hide_score_output.checked _.forEach(number_fields, field => { let str = _.get(data, field) if (str) { diff --git a/version.json b/version.json index 0660fe36f..359f74c53 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { - "tag_name": "1.18.0", - "release_name": "1.18.0", - "published_at": "2025-04-09T13:23:50Z", - "html_url": "https://github.com/codalab/codabench/releases/tag/1.18.0" + "tag_name": "1.19.0", + "release_name": "1.19.0", + "published_at": "2025-05-15", + "html_url": "https://github.com/codalab/codabench/releases/tag/1.19.0" }