From 96b2d1db73d38b04ed2a6ff2747c279bfe275150 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 10 Feb 2025 17:33:27 +0000 Subject: [PATCH 01/18] Update version.json for release 1.17.0 --- version.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version.json b/version.json index 43706bef6..eeedcc753 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { - "tag_name": "1.16.0", - "release_name": "1.16.0", - "published_at": "2025-01-16T16:11:05Z", - "html_url": "https://github.com/codalab/codabench/releases/tag/1.16.0" + "tag_name": "1.17.0", + "release_name": "1.17.0", + "published_at": "2025-02-10T17:33:09Z", + "html_url": "https://github.com/codalab/codabench/releases/tag/1.17.0" } From 9872f6b4215ed8f60e969a1868a402f2914e4193 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Tue, 11 Feb 2025 21:33:19 +0500 Subject: [PATCH 02/18] Updated release-version PR title to distinguish from other PRs (#1747) * Updated release-version PR title to distinguish from other PRs * Update release-version-update.yml --- .github/workflows/release-version-update.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-version-update.yml b/.github/workflows/release-version-update.yml index f2d8b93b3..a5eebaabe 100644 --- a/.github/workflows/release-version-update.yml +++ b/.github/workflows/release-version-update.yml @@ -62,8 +62,8 @@ jobs: 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": "Update version.json for release ${{ env.tag_name }}", - "body": "This PR updates version.json with the latest release information.", + "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" }' From fd77f262eefaf5a90e1246a0f644e5b51f4634da Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Thu, 13 Feb 2025 13:01:40 +0500 Subject: [PATCH 03/18] styling added for featured comps, featured comps moved to popular and featured sectio renamed to recent --- src/apps/api/serializers/competitions.py | 3 ++- src/apps/api/views/competitions.py | 8 +++--- src/apps/competitions/utils.py | 26 ++++++++++--------- .../competitions/tile/competition_tile.tag | 19 +++++++++++++- .../tile/front_page_competitions.tag | 6 ++--- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index 33bbefc39..b8f4c3285 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -449,6 +449,7 @@ class Meta: 'reward', 'contact_email', 'report', + 'is_featured', ) def get_created_by(self, obj): @@ -494,7 +495,7 @@ class Meta: class FrontPageCompetitionsSerializer(serializers.Serializer): popular_comps = CompetitionSerializerSimple(many=True) - featured_comps = CompetitionSerializerSimple(many=True) + recent_comps = CompetitionSerializerSimple(many=True) class PhaseResultsSubmissionSerializer(serializers.Serializer): diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 793e4efda..386773570 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -30,7 +30,7 @@ from competitions.models import Competition, Phase, CompetitionCreationTaskStatus, CompetitionParticipant, Submission from datasets.models import Data from competitions.tasks import batch_send_email, manual_migration, create_competition_dump -from competitions.utils import get_popular_competitions, get_featured_competitions +from competitions.utils import get_popular_competitions, get_recent_competitions from leaderboards.models import Leaderboard from utils.data import make_url_sassy from api.permissions import IsOrganizerOrCollaborator @@ -507,12 +507,12 @@ def creation_status(self, request, pk): @action(detail=False, methods=('GET',), permission_classes=(AllowAny,)) def front_page(self, request): popular_comps = get_popular_competitions() - featured_comps = get_featured_competitions() + recent_comps = get_recent_competitions(exclude_comps=popular_comps) popular_comps_serializer = CompetitionSerializerSimple(popular_comps, many=True) - featured_comps_serializer = CompetitionSerializerSimple(featured_comps, many=True) + recent_comps_serializer = CompetitionSerializerSimple(recent_comps, many=True) return Response(data={ "popular_comps": popular_comps_serializer.data, - "featured_comps": featured_comps_serializer.data + "recent_comps": recent_comps_serializer.data }) @swagger_auto_schema(request_body=no_body, responses={201: CompetitionCreationTaskStatusSerializer()}) diff --git a/src/apps/competitions/utils.py b/src/apps/competitions/utils.py index be8f3c699..aa52f848e 100644 --- a/src/apps/competitions/utils.py +++ b/src/apps/competitions/utils.py @@ -2,22 +2,21 @@ This file contains utilities for competitions ''' import random -# from django.db.models import Count from competitions.models import Competition def get_popular_competitions(limit=4): - ''' + """ Function to return most popular competitions based on the amount of participants. :param limit: Amount of competitions to return. Default is 3. :rtype: list :return: Most popular competitions. - ''' + """ competitions = Competition.objects.filter(published=True) \ - .order_by('-participants_count') + .order_by('-is_featured', '-participants_count') if len(competitions) <= limit: return competitions @@ -25,16 +24,19 @@ def get_popular_competitions(limit=4): return competitions[:limit] -def get_featured_competitions(limit=4): - ''' - Function to return featured competitions +def get_recent_competitions(exclude_comps=None, limit=4): + """ + Function to return recent competitions, excluding given and featured competitions. - :param limit: Amount of competitions to return. Default is 4 + :param limit: Amount of competitions to return. Default is 4. + :param exclude_comps: A queryset or list of competitions to exclude. :rtype: list - :return: list of featured competitions - ''' - - competitions = Competition.objects.filter(is_featured=True) + :return: List of featured competitions. + """ + exclude_ids = [comp.id for comp in exclude_comps] if exclude_comps else [] + competitions = Competition.objects.filter(published=True, is_featured=False) \ + .exclude(id__in=exclude_ids) \ + .order_by('-created_when') if len(competitions) <= limit: return competitions diff --git a/src/static/riot/competitions/tile/competition_tile.tag b/src/static/riot/competitions/tile/competition_tile.tag index 3ec1e7925..953e73306 100644 --- a/src/static/riot/competitions/tile/competition_tile.tag +++ b/src/static/riot/competitions/tile/competition_tile.tag @@ -1,6 +1,7 @@ -
+ + From fd27649deb2e8ceaaad0ea29b8726e9a31219ac4 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Fri, 14 Mar 2025 11:42:29 +0500 Subject: [PATCH 13/18] do not allow special chars in usernames --- src/apps/profiles/forms.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/apps/profiles/forms.py b/src/apps/profiles/forms.py index 3adb4d8e3..2a379f07a 100644 --- a/src/apps/profiles/forms.py +++ b/src/apps/profiles/forms.py @@ -1,3 +1,4 @@ +import re from django import forms from django.contrib.auth.forms import UserCreationForm from .models import User @@ -11,12 +12,13 @@ class SignUpForm(UserCreationForm): def clean_username(self): data = self.cleaned_data["username"] - if not data.islower(): - raise forms.ValidationError("Usernames should be in lowercase") - if not data.isalnum(): - raise forms.ValidationError( - "Usernames should not contain special characters." - ) + + # Check if username has allowed characters only + # Allow only lowercase letters, numbers, hyphens, and underscores + if not re.match(r"^[a-z0-9_-]+$", data): + raise forms.ValidationError("Username can only contain lowercase letters, numbers, hyphens, and underscores.") + + # Check username length if (len(data) > 15) or (len(data) < 5): raise forms.ValidationError( "Username must have at least 5 characters and at most 15 characters" From 7203cae48e01051861e2fcb419eef74c0189f9e4 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Fri, 21 Mar 2025 05:42:08 +0500 Subject: [PATCH 14/18] Enable/Disable competition forum (#1774) * forum enable/disable functionality added * new forum_enabled field added to competition dump data --- src/apps/api/serializers/competitions.py | 4 +++- src/apps/competitions/admin.py | 2 +- .../0053_competition_forum_enabled.py | 18 ++++++++++++++++++ src/apps/competitions/models.py | 3 +++ src/apps/competitions/tasks.py | 3 ++- src/apps/competitions/unpackers/v2.py | 1 + src/apps/forums/views.py | 5 +++++ src/static/riot/competitions/detail/_tabs.tag | 2 +- .../editor/_competition_details.tag | 18 ++++++++++++++++++ 9 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/apps/competitions/migrations/0053_competition_forum_enabled.py diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index b8f4c3285..f617fa6b9 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -267,7 +267,8 @@ class Meta: 'reward', 'contact_email', 'report', - 'whitelist_emails' + 'whitelist_emails', + 'forum_enabled' ) def validate_phases(self, phases): @@ -391,6 +392,7 @@ class Meta: 'contact_email', 'report', 'whitelist_emails', + 'forum_enabled' ) def get_leaderboards(self, instance): diff --git a/src/apps/competitions/admin.py b/src/apps/competitions/admin.py index d01967c0a..b991555da 100644 --- a/src/apps/competitions/admin.py +++ b/src/apps/competitions/admin.py @@ -5,7 +5,7 @@ class CompetitionAdmin(admin.ModelAdmin): search_fields = ['title', 'docker_image', 'created_by__username'] - list_display = ['title', 'created_by', 'is_featured'] + list_display = ['id', 'title', 'created_by', 'is_featured'] list_filter = ['is_featured'] diff --git a/src/apps/competitions/migrations/0053_competition_forum_enabled.py b/src/apps/competitions/migrations/0053_competition_forum_enabled.py new file mode 100644 index 000000000..6caa8279e --- /dev/null +++ b/src/apps/competitions/migrations/0053_competition_forum_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-03-10 14:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0052_auto_20250129_1058'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='forum_enabled', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index cd381b56b..c69ea56d8 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -87,6 +87,9 @@ class Competition(ChaHubSaveMixin, models.Model): # Count of participants in this competition (default = 1 because competition creator is also a participant) participants_count = models.PositiveIntegerField(default=1) + # If true, forum is enabled (default=True) + forum_enabled = models.BooleanField(default=True) + def __str__(self): return f"competition-{self.title}-{self.pk}-{self.competition_type}" diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index f56696940..412e49437 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -44,7 +44,8 @@ "queue", "description", "registration_auto_approve", - "enable_detailed_results" + "enable_detailed_results", + "forum_enabled" ] TASK_FIELDS = [ diff --git a/src/apps/competitions/unpackers/v2.py b/src/apps/competitions/unpackers/v2.py index b825e9ee2..85eddefcb 100644 --- a/src/apps/competitions/unpackers/v2.py +++ b/src/apps/competitions/unpackers/v2.py @@ -25,6 +25,7 @@ def __init__(self, *args, **kwargs): "fact_sheet": self.competition_yaml.get("fact_sheet", None), "reward": self.competition_yaml.get("reward", None), "contact_email": self.competition_yaml.get("contact_email", None), + "forum_enabled": self.competition_yaml.get("forum_enabled", True), "pages": [], "phases": [], "leaderboards": [], diff --git a/src/apps/forums/views.py b/src/apps/forums/views.py index d74d59509..5896ed8d5 100644 --- a/src/apps/forums/views.py +++ b/src/apps/forums/views.py @@ -26,6 +26,11 @@ class ForumBaseMixin(object): def dispatch(self, *args, **kwargs): # Get object early so we can access it in multiple places self.forum = get_object_or_404(Forum, pk=self.kwargs['forum_pk']) + + if not self.forum.competition.forum_enabled: + messages.error(self.request, "The forum for this competition is disabled.") + return redirect("competitions:detail", pk=self.forum.competition.pk) + if 'thread_pk' in self.kwargs: self.thread = get_object_or_404(Thread, pk=self.kwargs['thread_pk']) diff --git a/src/static/riot/competitions/detail/_tabs.tag b/src/static/riot/competitions/detail/_tabs.tag index 888ba735b..e1bb09770 100644 --- a/src/static/riot/competitions/detail/_tabs.tag +++ b/src/static/riot/competitions/detail/_tabs.tag @@ -7,7 +7,7 @@
Phases
My Submissions
Results
- Forum + Forum @@ -297,6 +313,7 @@ self.data["show_detailed_results_in_leaderboard"] = self.refs.show_detailed_results_in_leaderboard.checked self.data["auto_run_submissions"] = self.refs.auto_run_submissions.checked self.data["can_participants_make_submissions_public"] = self.refs.can_participants_make_submissions_public.checked + self.data["forum_enabled"] = self.refs.forum_enabled.checked self.data["make_programs_available"] = self.refs.make_programs_available.checked self.data["make_input_data_available"] = self.refs.make_input_data_available.checked self.data["docker_image"] = $(self.refs.docker_image).val() @@ -434,6 +451,7 @@ self.refs.show_detailed_results_in_leaderboard.checked = competition.show_detailed_results_in_leaderboard self.refs.auto_run_submissions.checked = competition.auto_run_submissions self.refs.can_participants_make_submissions_public.checked = competition.can_participants_make_submissions_public + self.refs.forum_enabled.checked = competition.forum_enabled self.refs.make_programs_available.checked = competition.make_programs_available self.refs.make_input_data_available.checked = competition.make_input_data_available $(self.refs.docker_image).val(competition.docker_image) From 661a680464efafdf271a077f68ce34b970b98eea Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Fri, 21 Mar 2025 17:10:07 +0500 Subject: [PATCH 15/18] Email in lowercase (#1769) * on signup email stored in lower case letters. Whitelist emails conveted to lowercased * whitespace removed * convert email to lowercased during login --- src/apps/api/views/competitions.py | 6 ++++-- src/apps/profiles/views.py | 21 ++++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index f65db03da..4f53f7f31 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -304,7 +304,8 @@ def update(self, request, *args, **kwargs): data.pop('whitelist_emails', None) # Loop over whitelist emails and add them back to whitelist emails in dict format for email in whitelist_emails: - data.setdefault('whitelist_emails', []).append({'email': email}) + # user lower case email because some emails in the whitelist may have upper case letters + data.setdefault('whitelist_emails', []).append({'email': email.lower()}) serializer = self.get_serializer(instance, data=data, partial=partial) serializer.is_valid(raise_exception=True) @@ -350,7 +351,8 @@ def register(self, request, pk): send_participation_accepted_emails(participant) else: # check if user is in whitelist emails then approve directly - if user.email in list(competition.whitelist_emails.values_list('email', flat=True)): + # Using lower case because some users have used uppercased emails addresses + if user.email.lower() in list(competition.whitelist_emails.values_list('email', flat=True)): participant.status = 'approved' send_participation_accepted_emails(participant) else: diff --git a/src/apps/profiles/views.py b/src/apps/profiles/views.py index 6c3a1533b..9fbc2daba 100644 --- a/src/apps/profiles/views.py +++ b/src/apps/profiles/views.py @@ -209,18 +209,24 @@ def sign_up(request): form = SignUpForm(request.POST) if form.is_valid(): # Check if the email is in the DeletedUser table - email = form.cleaned_data.get('email') + email = form.cleaned_data.get('email').lower() if DeletedUser.objects.filter(email=email).exists(): messages.error(request, "This email has been previously deleted and cannot be used.") context['form'] = form else: - form.save() + # Update the email field to lowercase before saving + form.cleaned_data['email'] = email + user = form.save(commit=False) # Get the user instance without saving + user.email = email # Ensure email is stored in lowercase + user.is_active = False # Set user as inactive + user.save() # Save user instance with updated email + + # Authenticate and send activation email username = form.cleaned_data.get('username') raw_password = form.cleaned_data.get('password1') user = authenticate(username=username, password=raw_password) - user.is_active = False - user.save() - activateEmail(request, user, form.cleaned_data.get('email')) + activateEmail(request, user, email) + return redirect('pages:home') else: context['form'] = form @@ -236,7 +242,7 @@ def resend_activation(request): form = ActivationForm(request.POST) if form.is_valid(): - email = form.cleaned_data.get('email') + email = form.cleaned_data.get('email').lower() user = User.objects.filter(email=email).first() if user and not user.is_active: @@ -271,7 +277,8 @@ def log_in(request): if form.is_valid(): # Get username and password - username = form.cleaned_data.get('username') + # use lowecased username/email + username = form.cleaned_data.get('username').lower() password = form.cleaned_data.get('password') # Check if the user exists From 5b3fbd8106cd612757e21fb74887aeb92662feae Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Fri, 21 Mar 2025 18:11:22 +0500 Subject: [PATCH 16/18] latest competition fields added to dump (#1786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Adrien Pavão --- src/apps/competitions/tasks.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 412e49437..87501be20 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -40,11 +40,21 @@ COMPETITION_FIELDS = [ "title", + "description", "docker_image", "queue", - "description", "registration_auto_approve", "enable_detailed_results", + "show_detailed_results_in_submission_panel", + "show_detailed_results_in_leaderboard", + "auto_run_submissions", + "can_participants_make_submissions_public", + "make_programs_available", + "make_input_data_available", + "competition_type", + "reward", + "contact_email", + "fact_sheet", "forum_enabled" ] From 288334992bf36534999d1fbae5fd3eefdec68c1e Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Sun, 23 Mar 2025 05:18:57 +0500 Subject: [PATCH 17/18] User quota is updated to GB from Bytes (#1749) * user assigned quota will now be in GB instead of bytes * unused counter removed --- src/apps/api/tests/test_datasets.py | 15 ++++++++++++--- src/apps/api/views/datasets.py | 3 ++- src/apps/api/views/tasks.py | 5 ++++- src/apps/profiles/models.py | 4 ++++ src/apps/profiles/quota.py | 17 +++++++++++++++++ src/settings/base.py | 2 +- src/static/riot/quota_management.tag | 2 +- src/utils/data.py | 5 +++++ 8 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 src/apps/profiles/quota.py diff --git a/src/apps/api/tests/test_datasets.py b/src/apps/api/tests/test_datasets.py index 944981757..5071a5555 100644 --- a/src/apps/api/tests/test_datasets.py +++ b/src/apps/api/tests/test_datasets.py @@ -3,7 +3,7 @@ from rest_framework.test import APITestCase from datasets.models import Data from factories import UserFactory, DataFactory -from utils.data import pretty_bytes +from utils.data import pretty_bytes, gb_to_bytes faker = Factory.create() @@ -50,10 +50,19 @@ def test_dataset_api_checks_for_authentication(self): def test_dataset_api_check_quota(self): self.client.login(username='creator', password='creator') + # User quota is in GB quota = float(self.creator.quota) + # Convert to bytes to compute available space + quota = gb_to_bytes(quota) + # Used storage is in bytes storage_used = float(self.creator.get_used_storage_space()) + available_space = quota - storage_used - file_size = 1024 * 1024 * 1024 * 1024 + + # 1 GB = 1,000,000,000 Bytes + # 1 TB = 1,000 GB = 1,000,000,000,000 Bytes + # Using a big file size of 1 TB to run the test + file_size = 1000 * 1000 * 1000 * 1000 # Fake upload a very big dataset resp = self.client.post(reverse("data-list"), { @@ -68,7 +77,7 @@ def test_dataset_api_check_quota(self): 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 + file_size = available_space - 1000 resp = self.client.post(reverse("data-list"), { 'name': 'new-file-test', 'type': Data.COMPETITION_BUNDLE, diff --git a/src/apps/api/views/datasets.py b/src/apps/api/views/datasets.py index c3c48c6ef..dade6bff9 100644 --- a/src/apps/api/views/datasets.py +++ b/src/apps/api/views/datasets.py @@ -14,7 +14,7 @@ from api.serializers import datasets as serializers from datasets.models import Data, DataGroup from competitions.models import CompetitionCreationTaskStatus -from utils.data import make_url_sassy, pretty_bytes +from utils.data import make_url_sassy, pretty_bytes, gb_to_bytes class DataViewSet(ModelViewSet): @@ -90,6 +90,7 @@ def create(self, request, *args, **kwargs): # Check User quota storage_used = float(request.user.get_used_storage_space()) quota = float(request.user.quota) + quota = gb_to_bytes(quota) file_size = float(request.data['file_size']) if storage_used + file_size > quota: available_space = pretty_bytes(quota - storage_used) diff --git a/src/apps/api/views/tasks.py b/src/apps/api/views/tasks.py index 16479a701..31a3b027c 100644 --- a/src/apps/api/views/tasks.py +++ b/src/apps/api/views/tasks.py @@ -19,7 +19,7 @@ from profiles.models import User from tasks.models import Task from datasets.models import Data -from utils.data import pretty_bytes +from utils.data import pretty_bytes, gb_to_bytes # TODO:// TaskViewSimple uses simple serializer from tasks, which exists purely for the use of Select2 on phase modal @@ -210,7 +210,10 @@ def upload_task(self, request): # Check if user has enough quota to proceed storage_used = float(request.user.get_used_storage_space()) + # User quota is in GB quota = float(request.user.quota) + # Convert user quota to bytes + quota = gb_to_bytes(quota) file_size = uploaded_file.size if storage_used + file_size > quota: file_size = pretty_bytes(file_size) diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index d66be9804..fe0252ec7 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -154,6 +154,10 @@ def get_chahub_is_valid(self): return True def get_used_storage_space(self, binary=False): + """ + Function to calculate storage used by a user + Returns in bytes + """ factor = 1024 if binary else 1000 from datasets.models import Data diff --git a/src/apps/profiles/quota.py b/src/apps/profiles/quota.py new file mode 100644 index 000000000..3a2e06571 --- /dev/null +++ b/src/apps/profiles/quota.py @@ -0,0 +1,17 @@ +import logging +from .models import User + +logger = logging.getLogger() + + +def reset_all_users_quota_to_gb(): + """ + Converts user quota from bytes to GB if it's stored in bytes. + Skips users whose quota is already in GB. + """ + users = User.objects.all() + for user in users: + # If quota is in bytes (greater than 1 GB in bytes) + if user.quota > 1000 * 1000 * 1000: + user.quota = user.quota / 1e9 # Convert to GB + user.save() diff --git a/src/settings/base.py b/src/settings/base.py index a8e297519..7fc4ab41b 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -420,7 +420,7 @@ GS_BUCKET_NAME = GS_PUBLIC_BUCKET_NAME # Default bucket set to public bucket # Quota -DEFAULT_USER_QUOTA = 15 * 1000 * 1000 * 1000 # 15GB +DEFAULT_USER_QUOTA = 15 # 15GB # ============================================================================= diff --git a/src/static/riot/quota_management.tag b/src/static/riot/quota_management.tag index b10da25f4..fb3dca8ad 100644 --- a/src/static/riot/quota_management.tag +++ b/src/static/riot/quota_management.tag @@ -7,7 +7,7 @@
- Quota: {formatSize(storage_used)} / {formatSize(quota)} + Quota: {formatSize(storage_used)} / {quota} GB
diff --git a/src/utils/data.py b/src/utils/data.py index b321d0a6a..bb344c47e 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -131,3 +131,8 @@ def pretty_bytes(bytes, decimal_places=1, suffix="B", binary=False): bytes /= factor return f"{bytes:.{decimal_places}f}{units[-1]}{suffix}" + + +def gb_to_bytes(gb, binary=False): + factor = 1024**3 if binary else 1000**3 + return gb * factor From 68a1e08eecb779ba2a7df8f5bad16a532c5770cc Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Tue, 25 Mar 2025 02:35:05 +0500 Subject: [PATCH 18/18] File Sizes cleanup (#1752) * sizes Kib to Bytes, size formatting functions cleanup * space between size and unit, removed factor multiplication from size calcualtion * file_size to bytes in SubmissionDetail * added missing migration * migration conflict resolved --- compute_worker/compute_worker.py | 2 +- .../migrations/0002_auto_20250218_1143.py | 18 ++++++++++++ src/apps/analytics/models.py | 4 +-- src/apps/analytics/tasks.py | 24 +++++++-------- src/apps/api/tests/test_datasets.py | 6 ++-- .../migrations/0053_auto_20250218_1151.py | 28 ++++++++++++++++++ .../migrations/0054_auto_20250324_0622.py | 18 ++++++++++++ .../migrations/0055_merge_20250324_0650.py | 14 +++++++++ src/apps/competitions/models.py | 16 ++++------ .../migrations/0010_auto_20250218_1100.py | 18 ++++++++++++ src/apps/datasets/models.py | 7 ++--- src/apps/pages/views.py | 22 ++------------ src/apps/profiles/models.py | 7 ++--- src/static/js/ours/utils.js | 11 +++++-- .../riot/analytics/_competitions_usage.tag | 5 +--- src/static/riot/analytics/_users_usage.tag | 10 ++----- .../riot/competitions/bundle_management.tag | 28 +----------------- src/static/riot/competitions/detail/_tabs.tag | 2 +- src/static/riot/datasets/management.tag | 29 +------------------ src/static/riot/quota_management.tag | 7 +---- .../riot/submissions/resource_submissions.tag | 29 +------------------ src/utils/data.py | 12 +++++++- 22 files changed, 158 insertions(+), 159 deletions(-) create mode 100644 src/apps/analytics/migrations/0002_auto_20250218_1143.py create mode 100644 src/apps/competitions/migrations/0053_auto_20250218_1151.py create mode 100644 src/apps/competitions/migrations/0054_auto_20250324_0622.py create mode 100644 src/apps/competitions/migrations/0055_merge_20250324_0650.py create mode 100644 src/apps/datasets/migrations/0010_auto_20250218_1100.py diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 06875919d..34ea6a303 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -161,7 +161,7 @@ def get_folder_size_in_gb(folder): total_size += os.path.getsize(path) elif os.path.isdir(path): total_size += get_folder_size_in_gb(path) - return total_size / 1024 / 1024 / 1024 + return total_size / 1000 / 1000 / 1000 # GB: decimal system (1000^3) def delete_files_in_folder(folder): diff --git a/src/apps/analytics/migrations/0002_auto_20250218_1143.py b/src/apps/analytics/migrations/0002_auto_20250218_1143.py new file mode 100644 index 000000000..32e8b0437 --- /dev/null +++ b/src/apps/analytics/migrations/0002_auto_20250218_1143.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-02-18 11:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('analytics', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='adminstoragedatapoint', + name='backups_total', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True), + ), + ] diff --git a/src/apps/analytics/models.py b/src/apps/analytics/models.py index f0afe09f3..976df8c1a 100644 --- a/src/apps/analytics/models.py +++ b/src/apps/analytics/models.py @@ -48,7 +48,7 @@ class UserStorageDataPoint(models.Model): class AdminStorageDataPoint(models.Model): backups_total = models.DecimalField( - max_digits=14, decimal_places=2, null=True, blank=True - ) + max_digits=20, decimal_places=2, null=True, blank=True + ) # stores bytes at_date = models.DateTimeField() created_at = models.DateTimeField(auto_now_add=True) diff --git a/src/apps/analytics/tasks.py b/src/apps/analytics/tasks.py index 238916143..d07fbc313 100644 --- a/src/apps/analytics/tasks.py +++ b/src/apps/analytics/tasks.py @@ -43,8 +43,8 @@ def create_storage_analytics_snapshot(): for dataset in Data.objects.filter(Q(file_size__isnull=True) | Q(file_size__lt=0)): try: dataset.file_size = Decimal( - dataset.data_file.size / 1024 - ) # file_size is in KiB + dataset.data_file.size + ) # file_size is in Bytes except Exception: dataset.file_size = Decimal(-1) finally: @@ -56,8 +56,8 @@ def create_storage_analytics_snapshot(): ): try: submission.prediction_result_file_size = Decimal( - submission.prediction_result.size / 1024 - ) # prediction_result_file_size is in KiB + submission.prediction_result.size + ) # prediction_result_file_size is in Bytes except Exception: submission.prediction_result_file_size = Decimal(-1) finally: @@ -68,8 +68,8 @@ def create_storage_analytics_snapshot(): ): try: submission.scoring_result_file_size = Decimal( - submission.scoring_result.size / 1024 - ) # scoring_result_file_size is in KiB + submission.scoring_result.size + ) # scoring_result_file_size is in Bytes except Exception: submission.scoring_result_file_size = Decimal(-1) finally: @@ -80,8 +80,8 @@ def create_storage_analytics_snapshot(): ): try: submission.detailed_result_file_size = Decimal( - submission.detailed_result.size / 1024 - ) # detailed_result_file_size is in KiB + submission.detailed_result.size + ) # detailed_result_file_size is in Bytes except Exception: submission.detailed_result_file_size = Decimal(-1) finally: @@ -92,8 +92,8 @@ def create_storage_analytics_snapshot(): ): try: submissiondetails.file_size = Decimal( - submissiondetails.data_file.size / 1024 - ) # file_size is in KiB + submissiondetails.data_file.size + ) # file_size is in Bytes except Exception: submissiondetails.file_size = Decimal(-1) finally: @@ -277,7 +277,7 @@ def create_storage_analytics_snapshot(): admin_storage_at_date[date] += size for date in admin_storage_day_range: - defaults = {"backups_total": admin_storage_at_date[date] / 1024.0} + defaults = {"backups_total": admin_storage_at_date[date]} lookup_params = {"at_date": date} AdminStorageDataPoint.objects.update_or_create( defaults=defaults, **lookup_params @@ -528,7 +528,7 @@ def create_storage_analytics_snapshot(): ) admin_data_point = AdminStorageDataPoint.objects.filter(at_date=date).first() admin_usage = (admin_data_point.backups_total or 0) if admin_data_point else 0 - orphaned_file_usage = Decimal(orphaned_files_size_per_date[date] / 1024) + orphaned_file_usage = Decimal(orphaned_files_size_per_date[date]) total_usage = ( users_usage + admin_usage + orphaned_file_usage ) # competitions_usage is included inside users_usage diff --git a/src/apps/api/tests/test_datasets.py b/src/apps/api/tests/test_datasets.py index 5071a5555..fb50b5f76 100644 --- a/src/apps/api/tests/test_datasets.py +++ b/src/apps/api/tests/test_datasets.py @@ -12,7 +12,7 @@ class DatasetAPITests(APITestCase): def setUp(self): self.creator = UserFactory(username='creator', password='creator') - self.existing_dataset = DataFactory(created_by=self.creator, name="Test!", file_size=1024) + self.existing_dataset = DataFactory(created_by=self.creator, name="Test!", file_size=1000) def test_dataset_api_checks_duplicate_names_for_same_user(self): self.client.login(username='creator', password='creator') @@ -23,7 +23,7 @@ def test_dataset_api_checks_duplicate_names_for_same_user(self): 'type': Data.COMPETITION_BUNDLE, 'request_sassy_file_name': faker.file_name(), 'file_name': faker.file_name(), - 'file_size': 1024, + 'file_size': 1000, }) assert resp.status_code == 400 @@ -34,7 +34,7 @@ def test_dataset_api_checks_duplicate_names_for_same_user(self): 'name': 'Test!', 'type': Data.COMPETITION_BUNDLE, 'request_sassy_file_name': faker.file_name(), - 'file_size': 1024, + 'file_size': 1000, }) assert resp.status_code == 200 diff --git a/src/apps/competitions/migrations/0053_auto_20250218_1151.py b/src/apps/competitions/migrations/0053_auto_20250218_1151.py new file mode 100644 index 000000000..c5562b388 --- /dev/null +++ b/src/apps/competitions/migrations/0053_auto_20250218_1151.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.28 on 2025-02-18 11:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0052_auto_20250129_1058'), + ] + + operations = [ + migrations.AlterField( + model_name='submission', + name='detailed_result_file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + migrations.AlterField( + model_name='submission', + name='prediction_result_file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + migrations.AlterField( + model_name='submission', + name='scoring_result_file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + ] diff --git a/src/apps/competitions/migrations/0054_auto_20250324_0622.py b/src/apps/competitions/migrations/0054_auto_20250324_0622.py new file mode 100644 index 000000000..2389f396a --- /dev/null +++ b/src/apps/competitions/migrations/0054_auto_20250324_0622.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-03-24 06:22 + +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/0055_merge_20250324_0650.py b/src/apps/competitions/migrations/0055_merge_20250324_0650.py new file mode 100644 index 000000000..d65c1e302 --- /dev/null +++ b/src/apps/competitions/migrations/0055_merge_20250324_0650.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.28 on 2025-03-24 06:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0054_auto_20250324_0622'), + ('competitions', '0053_competition_forum_enabled'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index c69ea56d8..4d938895f 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -466,18 +466,16 @@ class SubmissionDetails(models.Model): ] name = models.CharField(max_length=50) data_file = models.FileField(upload_to=PathWrapper('submission_details'), storage=BundleStorage) - file_size = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) # in KiB + file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes submission = models.ForeignKey('Submission', on_delete=models.CASCADE, related_name='details') is_scoring = models.BooleanField(default=False) def save(self, *args, **kwargs): if self.data_file and (not self.file_size or self.file_size == -1): try: - # save file size as KiB # self.data_file.size returns bytes - self.file_size = self.data_file.size / 1024 + self.file_size = self.data_file.size except TypeError: - # file returns a None size, can't divide None / 1024 # -1 indicates an error self.file_size = -1 except botocore.exceptions.ClientError: @@ -528,9 +526,9 @@ class Submission(ChaHubSaveMixin, models.Model): detailed_result = models.FileField(upload_to=PathWrapper('detailed_result'), null=True, blank=True, storage=BundleStorage) - prediction_result_file_size = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) # in KiB - scoring_result_file_size = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) # in KiB - detailed_result_file_size = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) # in KiB + prediction_result_file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes + scoring_result_file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes + detailed_result_file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes secret = models.UUIDField(default=uuid.uuid4) celery_task_id = models.UUIDField(null=True, blank=True) @@ -644,11 +642,9 @@ def save(self, ignore_submission_limit=False, **kwargs): for file_path_attr, file_size_attr in files_and_sizes_dict.items(): if getattr(self, file_path_attr) and (not getattr(self, file_size_attr) or getattr(self, file_size_attr) == -1): try: - # save file size as KiB # self.data_file.size returns bytes - setattr(self, file_size_attr, getattr(self, file_path_attr).size / 1024) + setattr(self, file_size_attr, getattr(self, file_path_attr).size) except TypeError: - # file returns a None size, can't divide None / 1024 # -1 indicates an error setattr(self, file_size_attr, Decimal(-1)) except botocore.exceptions.ClientError: diff --git a/src/apps/datasets/migrations/0010_auto_20250218_1100.py b/src/apps/datasets/migrations/0010_auto_20250218_1100.py new file mode 100644 index 000000000..3f2b30289 --- /dev/null +++ b/src/apps/datasets/migrations/0010_auto_20250218_1100.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-02-18 11:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasets', '0009_merge_20241203_1313'), + ] + + operations = [ + migrations.AlterField( + model_name='data', + name='file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + ] diff --git a/src/apps/datasets/models.py b/src/apps/datasets/models.py index a487304cb..67edb7343 100644 --- a/src/apps/datasets/models.py +++ b/src/apps/datasets/models.py @@ -59,7 +59,7 @@ class Data(ChaHubSaveMixin, models.Model): key = models.UUIDField(default=uuid.uuid4, blank=True, unique=True) is_public = models.BooleanField(default=False) upload_completed_successfully = models.BooleanField(default=False) - file_size = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) # in KiB + file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes # This is true if the Data model was created as part of unpacking a competition. Competition bundles themselves # are NOT marked True, since they are not created by unpacking! @@ -74,11 +74,10 @@ def get_download_url(self): def save(self, *args, **kwargs): if self.data_file and (not self.file_size or self.file_size == -1): try: - # save file size as KiB + # save file size in bytes # self.data_file.size returns bytes - self.file_size = self.data_file.size / 1024 + self.file_size = self.data_file.size except TypeError: - # file returns a None size, can't divide None / 1024 # -1 indicates an error self.file_size = Decimal(-1) except botocore.exceptions.ClientError: diff --git a/src/apps/pages/views.py b/src/apps/pages/views.py index 881ec885c..162d47fdf 100644 --- a/src/apps/pages/views.py +++ b/src/apps/pages/views.py @@ -6,6 +6,7 @@ from announcements.models import Announcement, NewsPost from django.shortcuts import render +from utils.data import pretty_bytes class HomeView(TemplateView): @@ -89,9 +90,9 @@ def get_context_data(self, *args, **kwargs): for submission in context['submissions']: # Get filesize from each submissions's data if submission.data: - submission.file_size = self.format_file_size(submission.data.file_size) + submission.file_size = pretty_bytes(submission.data.file_size) else: - submission.file_size = self.format_file_size(0) + submission.file_size = pretty_bytes(0) # Get queue from each submission queue_name = "" @@ -110,23 +111,6 @@ def get_context_data(self, *args, **kwargs): return context - def format_file_size(self, file_size): - """ - A custom function to convert file size to KB, MB, GB and return with the unit - """ - try: - n = float(file_size) - except Exception: - return "" - - units = ['KB', 'MB', 'GB'] - i = 0 - while n >= 1000 and i < len(units) - 1: - n /= 1000 - i += 1 - - return f"{n:.1f} {units[i]}" - class MonitorQueuesView(TemplateView): template_name = 'pages/monitor_queues.html' diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index fe0252ec7..3a660440d 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -159,7 +159,6 @@ def get_used_storage_space(self, binary=False): Returns in bytes """ - factor = 1024 if binary else 1000 from datasets.models import Data from competitions.models import Submission, SubmissionDetails @@ -170,7 +169,7 @@ def get_used_storage_space(self, binary=False): created_by_id=self.id, file_size__gt=0, file_size__isnull=False ).aggregate(Sum("file_size"))["file_size__sum"] - storage_used += users_datasets * factor if users_datasets else 0 + storage_used += users_datasets if users_datasets else 0 # Submissions users_submissions = Submission.objects.filter(owner_id=self.id).aggregate( @@ -202,14 +201,14 @@ def get_used_storage_space(self, binary=False): ) ) - storage_used += users_submissions["size"] * factor if users_submissions["size"] else 0 + storage_used += users_submissions["size"] if users_submissions["size"] else 0 # Submissions details users_submissions_details = SubmissionDetails.objects.filter( submission__owner_id=self.id, file_size__gt=0, file_size__isnull=False ).aggregate(Sum("file_size"))["file_size__sum"] - storage_used += users_submissions_details * factor if users_submissions_details else 0 + storage_used += users_submissions_details if users_submissions_details else 0 return storage_used diff --git a/src/static/js/ours/utils.js b/src/static/js/ours/utils.js index 7b2f7cdeb..a70675222 100644 --- a/src/static/js/ours/utils.js +++ b/src/static/js/ours/utils.js @@ -90,16 +90,23 @@ function pretty_date(date_string) { } function pretty_bytes(bytes, decimalPlaces = 1, suffix = "B", binary = false) { + + // Ensure bytes is a valid number + bytes = parseFloat(bytes) + if (isNaN(bytes) || bytes < 0) { + return "" // Return empty string for invalid or negative values + } + const factor = binary ? 1024.0 : 1000.0; const units = binary ? ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi'] : ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z']; for (const unit of units) { if (Math.abs(bytes) < factor || unit === units[units.length - 1]) { - return bytes.toFixed(decimalPlaces) + unit + suffix; + return bytes.toFixed(decimalPlaces) + ' ' + unit + suffix; } bytes /= factor; } - return bytes.toFixed(decimalPlaces) + units[units.length - 1] + suffix; + return bytes.toFixed(decimalPlaces) + ' ' + units[units.length - 1] + suffix; } /* ---------------------------------------------------------------------------- diff --git a/src/static/riot/analytics/_competitions_usage.tag b/src/static/riot/analytics/_competitions_usage.tag index 353fb0684..b7897f7a0 100644 --- a/src/static/riot/analytics/_competitions_usage.tag +++ b/src/static/riot/analytics/_competitions_usage.tag @@ -42,7 +42,7 @@ { competitionUsage.title } { competitionUsage.organizer } { formatDate(competitionUsage.created_when) } - { formatSize(competitionUsage.datasets) } + { pretty_bytes(competitionUsage.datasets) } @@ -474,9 +474,6 @@ return datetime.fromJSDate(date).toISODate(); } - self.formatSize = function(size) { - return pretty_bytes(size); - } self.downloadCompetitionsHistory = function() { var csv = []; diff --git a/src/static/riot/analytics/_users_usage.tag b/src/static/riot/analytics/_users_usage.tag index b83a8a52c..62c8d33eb 100644 --- a/src/static/riot/analytics/_users_usage.tag +++ b/src/static/riot/analytics/_users_usage.tag @@ -47,9 +47,9 @@ { userUsage.name } { formatDate(userUsage.date_joined) } - { formatSize(userUsage.datasets) } - { formatSize(userUsage.submissions) } - { formatSize(userUsage.datasets + userUsage.submissions) } + { pretty_bytes(userUsage.datasets) } + { pretty_bytes(userUsage.submissions) } + { pretty_bytes(userUsage.datasets + userUsage.submissions) } @@ -554,10 +554,6 @@ return datetime.fromJSDate(date).toISODate(); } - self.formatSize = function(size) { - return pretty_bytes(size); - } - self.downloadUsersHistory = function() { var csv = []; diff --git a/src/static/riot/competitions/bundle_management.tag b/src/static/riot/competitions/bundle_management.tag index 78372fee3..635bc9f99 100644 --- a/src/static/riot/competitions/bundle_management.tag +++ b/src/static/riot/competitions/bundle_management.tag @@ -32,7 +32,7 @@ {dataset.competition.title} - { format_file_size(dataset.file_size) } + { pretty_bytes(dataset.file_size) } { timeSince(Date.parse(dataset.created_when)) } ago