diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index 2d1e1639c..e3bc20fb7 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -327,6 +327,7 @@ class CompetitionCreateSerializer(CompetitionSerializer): class CompetitionDetailSerializer(serializers.ModelSerializer): created_by = serializers.CharField(source='created_by.username', read_only=True) + logo_icon = NamedBase64ImageField(allow_null=True) pages = PageSerializer(many=True) phases = PhaseDetailSerializer(many=True) leaderboards = serializers.SerializerMethodField() @@ -347,6 +348,7 @@ class Meta: 'created_by', 'created_when', 'logo', + 'logo_icon', 'terms', 'pages', 'phases', diff --git a/src/apps/api/serializers/queues.py b/src/apps/api/serializers/queues.py index deea441d0..b3d84aee6 100644 --- a/src/apps/api/serializers/queues.py +++ b/src/apps/api/serializers/queues.py @@ -3,8 +3,8 @@ from api.mixins import DefaultUserCreateMixin from queues.models import Queue - from profiles.models import User +from django.db.models import Q class OrganizerSerializer(serializers.ModelSerializer): @@ -86,3 +86,30 @@ class Meta: 'created_when', 'is_owner', ) + + +class QueueListSerializer(QueueSerializer): + competitions = serializers.SerializerMethodField() + + class Meta(QueueSerializer.Meta): + fields = QueueSerializer.Meta.fields + ('competitions',) + + def get_competitions(self, obj): + # get user from the context request + user = self.context['request'].user + + # for super user return all competiitons using this queue + # for admin return competitions where this user is organizer using this queue + # for non-admin return public competitions using this queue + if user.is_superuser: + # Fetch all competitions + competitions = obj.competitions.all().values('id', 'title') + else: + # Fetch all competitions where user is organizer or competition is published + competitions = obj.competitions.filter( + Q(published=True) | + Q(created_by=user) | + Q(collaborators=user) + ).values('id', 'title') + + return competitions diff --git a/src/apps/api/serializers/submissions.py b/src/apps/api/serializers/submissions.py index ef3cdf39b..81e8de21c 100644 --- a/src/apps/api/serializers/submissions.py +++ b/src/apps/api/serializers/submissions.py @@ -86,6 +86,7 @@ class SubmissionLeaderBoardSerializer(serializers.ModelSerializer): display_name = serializers.CharField(source='owner.display_name') slug_url = serializers.CharField(source='owner.slug_url') organization = SimpleOrganizationSerializer(allow_null=True) + created_when = serializers.DateTimeField(format="%Y-%m-%d %H:%M") class Meta: model = Submission @@ -100,7 +101,8 @@ class Meta: 'display_name', 'slug_url', 'organization', - 'detailed_result' + 'detailed_result', + 'created_when' ) extra_kwargs = { "scores": {"read_only": True}, diff --git a/src/apps/api/tests/test_datasets.py b/src/apps/api/tests/test_datasets.py index 40b4dbf5d..944981757 100644 --- a/src/apps/api/tests/test_datasets.py +++ b/src/apps/api/tests/test_datasets.py @@ -65,7 +65,7 @@ def test_dataset_api_check_quota(self): }) assert resp.status_code == 400 - assert resp.data["data_file"][0] == f"Insufficient space. Your available space is {pretty_bytes(available_space)}. The file size is {pretty_bytes(file_size)}. Please free up some space and try again." + assert resp.data["data_file"][0] == f'Insufficient space. Your available space is {pretty_bytes(available_space)}. The file size is {pretty_bytes(file_size)}. Please free up some space and try again. You can manage your files in the Resources page.' # Fake upload a small file file_size = available_space - 1024 diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 565cdee27..3dc2f0bba 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -712,12 +712,6 @@ def get_leaderboard(self, request, pk): parent__isnull=False ).count() - # get date of last submission by the owner of this submission for this phase - last_entry_date = Submission.objects.filter(owner__username=submission['owner'], phase=phase)\ - .values('created_when')\ - .order_by('-created_when')[0]['created_when']\ - .strftime('%Y-%m-%d') - submission_key = f"{submission['owner']}{submission['parent'] or submission['id']}" # gather detailed result from submissions for each task @@ -741,7 +735,7 @@ def get_leaderboard(self, request, pk): 'slug_url': submission['slug_url'], 'organization': submission['organization'], 'num_entries': num_entries, - 'last_entry_date': last_entry_date + 'created_when': submission['created_when'] }) for score in submission['scores']: diff --git a/src/apps/api/views/datasets.py b/src/apps/api/views/datasets.py index 1f4fffa65..2175b9dc0 100644 --- a/src/apps/api/views/datasets.py +++ b/src/apps/api/views/datasets.py @@ -86,7 +86,7 @@ def create(self, request, *args, **kwargs): if storage_used + file_size > quota: available_space = pretty_bytes(quota - storage_used) file_size = pretty_bytes(file_size) - message = f"Insufficient space. Your available space is {available_space}. The file size is {file_size}. Please free up some space and try again." + message = f'Insufficient space. Your available space is {available_space}. The file size is {file_size}. Please free up some space and try again. You can manage your files in the Resources page.' return Response({'data_file': [message]}, status=status.HTTP_400_BAD_REQUEST) # All good, let's proceed diff --git a/src/apps/api/views/queues.py b/src/apps/api/views/queues.py index db96188c6..23e486e85 100644 --- a/src/apps/api/views/queues.py +++ b/src/apps/api/views/queues.py @@ -13,7 +13,7 @@ class QueueViewSet(ModelViewSet): queryset = Queue.objects.all() - serializer_class = serializers.QueueSerializer + serializer_class = serializers.QueueListSerializer filter_fields = ('owner', 'is_public', 'name') filter_backends = (DjangoFilterBackend, SearchFilter) search_fields = ('name',) @@ -29,7 +29,7 @@ def get_queryset(self): def get_serializer_class(self): if self.request.method == 'GET': - return serializers.QueueSerializer + return serializers.QueueListSerializer else: return serializers.QueueCreationSerializer diff --git a/src/apps/api/views/submissions.py b/src/apps/api/views/submissions.py index b61963f9b..f9582488d 100644 --- a/src/apps/api/views/submissions.py +++ b/src/apps/api/views/submissions.py @@ -270,7 +270,14 @@ def re_run_submission(self, request, pk): rerun_kwargs = {} new_sub = submission.re_run(**rerun_kwargs) - return Response({'id': new_sub.id}) + if new_sub is None: + # return error + return Response({ + "error_msg": "You cannot rerun this submission because one or more tasks this submission was running are deleted, resubmit the submission or contact the competition organizer!"}, + status=status.HTTP_404_NOT_FOUND + ) + else: + return Response({'id': new_sub.id}) @action(detail=False, methods=('POST',)) def re_run_many_submissions(self, request): diff --git a/src/apps/competitions/migrations/0045_auto_20240129_2314.py b/src/apps/competitions/migrations/0045_auto_20240129_2314.py new file mode 100644 index 000000000..2cb752724 --- /dev/null +++ b/src/apps/competitions/migrations/0045_auto_20240129_2314.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.17 on 2024-01-29 23:14 + +from django.db import migrations, models +import utils.data + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0044_merge_20231221_1416'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='logo_icon', + field=models.ImageField(blank=True, null=True, upload_to=utils.data.PathWrapper('logos', manual_override=True)), + ), + ] diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index 4b87ec35a..1f96cc84e 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -1,9 +1,12 @@ import logging import uuid +import os +import io from django.conf import settings from django.contrib.sites.models import Site from django.contrib.postgres.fields import JSONField +from django.core.files.base import ContentFile from django.db import models from django.db.models import Q from django.urls import reverse @@ -15,6 +18,7 @@ from profiles.models import User, Organization from utils.data import PathWrapper from utils.storage import BundleStorage +from PIL import Image from tasks.models import Task @@ -32,6 +36,7 @@ class Competition(ChaHubSaveMixin, models.Model): title = models.CharField(max_length=256) logo = models.ImageField(upload_to=PathWrapper('logos'), null=True, blank=True) + logo_icon = models.ImageField(upload_to=PathWrapper('logos', manual_override=True), null=True, blank=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="competitions") created_when = models.DateTimeField(default=now) @@ -214,8 +219,37 @@ def get_chahub_data(self): return self.clean_private_data(data) + def make_logo_icon(self): + if self.logo: + # Read the content of the logo file + self.logo.name + self.logo_icon + icon_dirname_only = os.path.dirname(self.logo.name) # Get just the path + icon_basename_only = os.path.basename(self.logo.name) # Get just the filename + file_name = os.path.splitext(icon_basename_only)[0] + ext = os.path.splitext(icon_basename_only)[1] + new_path = os.path.join(icon_dirname_only, f"{file_name}_icon{ext}") + logo_content = self.logo.read() + original_logo = Image.open(io.BytesIO(logo_content)) + # Resize the image to a smaller size for logo_icon + width, height = original_logo.size + new_width = 100 # Specify the desired width for the logo_icon + new_height = int((new_width / width) * height) + resized_logo = original_logo.resize((new_width, new_height)) + # Create a BytesIO object to save the resized image + icon_content = io.BytesIO() + resized_logo.save(icon_content, format='PNG') + # Save the resized logo as logo_icon + self.logo_icon.save(new_path, ContentFile(icon_content.getvalue()), save=False) + def save(self, *args, **kwargs): super().save(*args, **kwargs) + if not self.logo: + pass + elif not self.logo_icon: + self.make_logo_icon() + elif os.path.dirname(self.logo.name) != os.path.dirname(self.logo_icon.name): + self.make_logo_icon() to_create = User.objects.filter( Q(id=self.created_by_id) | Q(id__in=self.collaborators.all().values_list('id', flat=True)) ).exclude(id__in=self.participants.values_list('user_id', flat=True)).distinct() @@ -500,10 +534,17 @@ def __str__(self): return f"{self.phase.competition.title} submission PK={self.pk} by {self.owner.username}" def delete(self, **kwargs): + + # Check if any other submissions are using the same data + other_submissions_using_data = Submission.objects.filter(data=self.data).exclude(pk=self.pk).exists() + + if not other_submissions_using_data: + # If no other submissions are using the same data, delete it + self.data.delete() + # Also clean up details on delete self.details.all().delete() - # Call this here so that the data_file for the submission also gets deleted from storage - self.data.delete() + super().delete(**kwargs) def save(self, ignore_submission_limit=False, **kwargs): @@ -539,24 +580,51 @@ def start(self, tasks=None): run_submission(self.pk, tasks=tasks) def re_run(self, task=None): + + # task to use in the new submission + new_submission_task = task or self.task + + # set is_specific_task_re_run + is_specific_task_re_run = bool(task) + + flag_rerun_specific_task_or_has_no_children = False + # Check if this submission needs to rerun on specific children or has no children + if not self.has_children or is_specific_task_re_run: + flag_rerun_specific_task_or_has_no_children = True + + # Check if task exists in case of specific task rerun or no children + if flag_rerun_specific_task_or_has_no_children and new_submission_task is None: + logger.error(f"Cannot rerun `{self}` because the task is None (deleted)") + return None + else: + children_tasks = self.children.values_list('task', flat=True) + if None in children_tasks: + logger.error(f"Cannot rerun `{self}` because one or more children submission tasks are None (deleted)") + return None + + # Create a new submission submission_arg_dict = { 'owner': self.owner, - 'task': task or self.task, + 'task': new_submission_task, 'phase': self.phase, 'data': self.data, 'has_children': self.has_children, - 'is_specific_task_re_run': bool(task), + 'is_specific_task_re_run': is_specific_task_re_run, 'fact_sheet_answers': self.fact_sheet_answers, } sub = Submission(**submission_arg_dict) sub.save(ignore_submission_limit=True) - # No need to rerun on children if this is running on a specific task - if not self.has_children or sub.is_specific_task_re_run: - self.refresh_from_db() + # set tasks for rerunning + if flag_rerun_specific_task_or_has_no_children: + # in case of a submission with no children or specific task rerun + # submission with no children is same as submission with one task tasks = [sub.task] else: + # in case submission has multiple children or multiple task rerun + # tasks are gathered from the children submissions tasks = Task.objects.filter(pk__in=self.children.values_list('task', flat=True)) + sub.start(tasks=tasks) return sub diff --git a/src/apps/competitions/statistics.py b/src/apps/competitions/statistics.py new file mode 100644 index 000000000..d77cc624a --- /dev/null +++ b/src/apps/competitions/statistics.py @@ -0,0 +1,111 @@ +# -------------------------------------------------- +# Imports +# -------------------------------------------------- +import os +from competitions.models import Competition + +# -------------------------------------------------- +# Setting constants +# -------------------------------------------------- +BASE_URL = "https://www.codabench.org/competitions/" +STATISTICS_DIR = "/app/statistics/" +CSV_FILE_NAME = "codabench_competition_statistics.csv" +CSV_PATH = STATISTICS_DIR + CSV_FILE_NAME + + +def create_codabench_statistics(): + """ + This function prepares a CSV file with all published competitions + """ + + # Create statistics directory if not already createad + if not os.path.exists(STATISTICS_DIR): + os.makedirs(STATISTICS_DIR) + + # Write header of the CSV file + with open(CSV_PATH, 'w', newline='') as output_file: + # Header for the csv + header = 'title; description; participants; submissions; year; phases; reward; duration (days); url;\n' + output_file.write(header) + + # loop over published competitions + for comp in Competition.objects.filter(published=True): + + # get title + title = comp.title + title = clean_string(title) + + # get description + desc = comp.description + desc = clean_string(desc) + + # get participants + num_participants = comp.participants.count() + + # get phases + phases = comp.phases.all() + num_phases = len(phases) + + # get submissions + num_submissions = 0 + for phase in phases: + num_submissions += phase.submissions.count() + + # get competition first phase year + year = phases[0].start.year + + # get competition start and end date + start_date = phases[0].start + end_date = phases[num_phases - 1].end + # if last phase has no end date, set end date to last phase start date + if end_date is None: + end_date = phases[num_phases - 1].start + + # compute duration of the competition + duration = (end_date - start_date).days + + # get reward + reward = comp.reward + # set reward to empty string if none + if reward is None: + reward = "" + else: + reward = clean_string(reward) + + # prepare competition url + url = f"{BASE_URL}{comp.id}" + + # prepare a row with all the computed information for one competition + row = '{}; {}; {}; {}; {}; {}; {}; {}; {}; \n'.format( + title, + desc, + num_participants, + num_submissions, + year, + num_phases, + reward, + duration, + url + ) + + # write row in the CSV file + with open(CSV_PATH, 'a') as output_file: + output_file.write(row) + + +def clean_string(text): + """ + This function cleans an input text + """ + if ";" in text: + text = text.replace(";", ",") + + if '\n' in text: + text = text.replace(r'\n', ' ') + + if '\r' in text: + text = text.replace(r'\r', ' ') + + text = ''.join(text.splitlines()) + + return text diff --git a/src/static/riot/competitions/detail/_header.tag b/src/static/riot/competitions/detail/_header.tag index 87d4fe870..d6cda97d4 100644 --- a/src/static/riot/competitions/detail/_header.tag +++ b/src/static/riot/competitions/detail/_header.tag @@ -41,7 +41,7 @@
Organized by: - {competition.created_by} + {competition.created_by} ({competition.contact_email})
diff --git a/src/static/riot/competitions/detail/_tabs.tag b/src/static/riot/competitions/detail/_tabs.tag index 75bceec2a..1a9769790 100644 --- a/src/static/riot/competitions/detail/_tabs.tag +++ b/src/static/riot/competitions/detail/_tabs.tag @@ -50,17 +50,25 @@
- + +
- + +
+ Log In or + Sign Up to view availbale files. +
+ + +
- - diff --git a/src/static/riot/competitions/detail/leaderboards.tag b/src/static/riot/competitions/detail/leaderboards.tag index e21238b23..0617a38ea 100644 --- a/src/static/riot/competitions/detail/leaderboards.tag +++ b/src/static/riot/competitions/detail/leaderboards.tag @@ -35,7 +35,7 @@ - + @@ -58,7 +58,7 @@ - +
Download Phase Task TypeAvailable Available @@ -80,7 +88,7 @@ {file.task} {file.type} + {filesize(file.file_size * 1024)}# Participant EntriesDate of last entryDate {column.title}
{ submission.owner } { submission.organization.name } {submission.num_entries}{submission.last_entry_date}{submission.created_when} diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index 7b9aa43d7..e7a6f17ed 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -323,7 +323,11 @@ .fail(function (response) { if(response.responseJSON.detail){ toastr.error(response.responseJSON.detail) - } else { + } + else if(response.responseJSON.error_msg){ + toastr.error(response.responseJSON.error_msg) + } + else { toastr.error(response.responseText) } }) diff --git a/src/static/riot/competitions/detail/submission_upload.tag b/src/static/riot/competitions/detail/submission_upload.tag index 1e2d89508..75ecc8b2f 100644 --- a/src/static/riot/competitions/detail/submission_upload.tag +++ b/src/static/riot/competitions/detail/submission_upload.tag @@ -488,7 +488,7 @@ } } - toastr.error("Creation failed, error occurred") + toastr.error(`Creation failed, error occurred: ${response.responseJSON.data_file[0]}`) }) .always(function () { setTimeout(self.hide_progress_bar, 500) diff --git a/src/static/riot/competitions/editor/_competition_details.tag b/src/static/riot/competitions/editor/_competition_details.tag index 50f301342..c1121e555 100644 --- a/src/static/riot/competitions/editor/_competition_details.tag +++ b/src/static/riot/competitions/editor/_competition_details.tag @@ -109,7 +109,7 @@
- +
diff --git a/src/static/riot/competitions/public-list.tag b/src/static/riot/competitions/public-list.tag index 2eb854a08..c9c8e4680 100644 --- a/src/static/riot/competitions/public-list.tag +++ b/src/static/riot/competitions/public-list.tag @@ -13,7 +13,7 @@
- +
diff --git a/src/static/riot/queues/management.tag b/src/static/riot/queues/management.tag index be74a5132..ba138d6d9 100644 --- a/src/static/riot/queues/management.tag +++ b/src/static/riot/queues/management.tag @@ -133,11 +133,20 @@
Close
diff --git a/src/static/riot/tasks/management.tag b/src/static/riot/tasks/management.tag index 06d24bb2f..92e2eb506 100644 --- a/src/static/riot/tasks/management.tag +++ b/src/static/riot/tasks/management.tag @@ -639,7 +639,7 @@ } self.delete_task = function (task) { - if (confirm("Are you sure you want to delete '" + task.name + "'?")) { + if (confirm("Are you sure you want to delete '" + task.name + "'?\nSubmissions using this task cannot rerun! Results displayed on leaderboard can also be affected!")) { CODALAB.api.delete_task(task.id) .done(function () { self.update_tasks() @@ -654,7 +654,7 @@ } self.delete_tasks = function () { - if (confirm(`Are you sure you want to delete multiple tasks?`)) { + if (confirm(`Are you sure you want to delete multiple tasks?\nSubmissions using these tasks cannot rerun! Results displayed on leaderboard can also be affected!`)) { CODALAB.api.delete_tasks(self.marked_tasks) .done(function () { self.update_tasks() diff --git a/src/utils/data.py b/src/utils/data.py index 0303888d6..7d743f361 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -19,20 +19,28 @@ class PathWrapper(object): """Helper to generate UUID's in file names while maintaining their extension""" - def __init__(self, base_directory): + def __init__(self, base_directory, manual_override=False): self.path = base_directory + self.manual_override = manual_override def __call__(self, instance, filename): - name, extension = os.path.splitext(filename) - truncated_uuid = uuid.uuid4().hex[0:12] - truncated_name = name[0:35] - - return os.path.join( - self.path, - now().strftime('%Y-%m-%d-%s'), - truncated_uuid, - "{0}{1}".format(truncated_name, extension) - ) + if not self.manual_override: + name, extension = os.path.splitext(filename) + truncated_uuid = uuid.uuid4().hex[0:12] + truncated_name = name[0:35] + + path = os.path.join( + self.path, + now().strftime('%Y-%m-%d-%s'), + truncated_uuid, + "{0}{1}".format(truncated_name, extension) + ) + else: + path = os.path.join( + filename + ) + + return path def make_url_sassy(path, permission='r', duration=60 * 60 * 24, content_type='application/zip'):