From 6a5e796294ae0ccba1ba0d1b824602057fb59d77 Mon Sep 17 00:00:00 2001
From: Ihsan Ullah
Date: Tue, 28 Jan 2025 18:50:34 +0500
Subject: [PATCH 1/5] Platform yearly statistics (#1727)
* new statistics added
* flake-8 fixes
* total fixed for users, participants and submissions
---
src/apps/competitions/statistics.py | 148 +++++++++++++++++++++++++++-
1 file changed, 144 insertions(+), 4 deletions(-)
diff --git a/src/apps/competitions/statistics.py b/src/apps/competitions/statistics.py
index d77cc624a..4a06e0022 100644
--- a/src/apps/competitions/statistics.py
+++ b/src/apps/competitions/statistics.py
@@ -1,23 +1,163 @@
+"""
+This script is created to compute two types of statistics:
+ 1. Overall platform statistics for a specified year
+ 2. Overall published competitions statistics
+
+Usage:
+ Bash into django console
+ ```
+ docker compose exec django ./manage.py shell_plus
+ ```
+
+ For overall platform statistics
+ ```
+ from competitions.statistics import create_codabench_statistics
+ create_codabench_statistics(year=2024)
+
+ # if year is not specified, current year is used by default
+ # a csv file named codabench_statistics_2024.csv is generated in statistics folder (for year=2024)
+ ```
+
+ For overall published competitions statistics
+ ```
+ from competitions.statistics import create_codabench_statistics_published_comps
+ create_codabench_statistics_published_comps()
+
+ # a csv file named codabench_statistics_published_comps.csv is generated in statistics folder
+ ```
+"""
+
# --------------------------------------------------
# Imports
# --------------------------------------------------
import os
-from competitions.models import Competition
+from datetime import datetime
+from competitions.models import Competition, Submission, CompetitionParticipant
+from profiles.models import User
# --------------------------------------------------
# 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():
+def create_codabench_statistics(year=None):
+ """
+ This function prepares a CSV file with different statistics per month
+ """
+
+ # Set year to current year if None
+ if year is None:
+ year = datetime.now().year
+
+ # Create statistics directory if not already createad
+ if not os.path.exists(STATISTICS_DIR):
+ os.makedirs(STATISTICS_DIR)
+
+ rows_dict = {}
+
+ # Initialize sets for tracking total of users, participants and submissions
+ total_users = set()
+ total_participants = set()
+ total_submissions = set()
+
+ # Loop over months
+ for month in range(1, 13):
+
+ # count total competitions
+ tota_competitions_count = Competition.objects.filter(created_when__year=year, created_when__month=month).count()
+ rows_dict.setdefault("total_competitions", []).append(tota_competitions_count)
+
+ # count public competitions
+ public_competitions_count = Competition.objects.filter(created_when__year=year, created_when__month=month, published=True).count()
+ rows_dict.setdefault("public_competitions", []).append(public_competitions_count)
+
+ # count private competitions
+ private_competitions_count = Competition.objects.filter(created_when__year=year, created_when__month=month, published=False).count()
+ rows_dict.setdefault("private_competitions", []).append(private_competitions_count)
+
+ # Count new users
+ new_users_count = User.objects.filter(date_joined__year=year, date_joined__month=month).count()
+ rows_dict.setdefault("new_users", []).append(new_users_count)
+
+ # Count total users (including the current month)
+ new_user_ids = set(User.objects.filter(date_joined__year=year, date_joined__month=month).values_list('id', flat=True))
+ total_users.update(new_user_ids)
+ rows_dict.setdefault("total_users", []).append(len(total_users))
+
+ # Count new participants
+ new_participants_count = CompetitionParticipant.objects.filter(competition__created_when__year=year, competition__created_when__month=month).count()
+ rows_dict.setdefault("new_participants", []).append(new_participants_count)
+
+ # Count total participants (including the current month)
+ new_participants_ids = set(CompetitionParticipant.objects.filter(competition__created_when__year=year, competition__created_when__month=month).values_list('id', flat=True))
+ total_participants.update(new_participants_ids)
+ rows_dict.setdefault("total_participants", []).append(len(total_participants))
+
+ # Count new submissions
+ new_submissions_count = Submission.objects.filter(created_when__year=year, created_when__month=month).count()
+ rows_dict.setdefault("new_submissions", []).append(new_submissions_count)
+
+ # Submissions per day = total submissions/30
+ submissions_per_day = 0
+ if new_submissions_count > 0:
+ submissions_per_day = int(new_submissions_count / 30)
+ rows_dict.setdefault("submissions_per_day", []).append(submissions_per_day)
+
+ # Count successful submissions (i.e., those that are finished)
+ successful_submissions = Submission.objects.filter(created_when__year=year, created_when__month=month, status=Submission.FINISHED).count()
+ rows_dict.setdefault("finished_submissions", []).append(successful_submissions)
+
+ # Count failed submissions (i.e., those that are failed)
+ failed_submissions = Submission.objects.filter(created_when__year=year, created_when__month=month, status=Submission.FAILED).count()
+ rows_dict.setdefault("failed_submissions", []).append(failed_submissions)
+
+ # Count total submissions (including the current month)
+ new_submissions_ids = set(Submission.objects.filter(created_when__year=year, created_when__month=month).values_list('id', flat=True))
+ total_submissions.update(new_submissions_ids)
+ rows_dict.setdefault("total_submissions", []).append(len(total_submissions))
+
+ # Set CSV file and path
+ CSV_FILE_NAME = f"codabench_statistics_{year}.csv"
+ CSV_PATH = STATISTICS_DIR + CSV_FILE_NAME
+
+ # Define month abbreviations
+ month_abbr = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
+
+ # Open the CSV file in write mode
+ with open(CSV_PATH, 'w') as output_file:
+ # Write the header row only once if the file is empty
+ if output_file.tell() == 0:
+ header = f"{year}; " + "; ".join(month_abbr) + "; Total \n"
+ output_file.write(header)
+
+ # Loop over each metric in the rows_dict and write the corresponding row
+ for metric, values in rows_dict.items():
+
+ # for total_users, total_participants, and total_submissions
+ # total is the last value
+ # for others total is the sum
+ if metric in ["total_users", "total_participants", "total_submissions"]:
+ total = values[-1]
+ else:
+ # Calculate the total for the metric (sum of all monthly counts)
+ total = sum(values)
+
+ # Create a row with the metric name followed by the values for each month
+ row = f"{metric}; " + "; ".join(map(str, values)) + f"; {total} \n"
+ output_file.write(row)
+
+
+def create_codabench_statistics_published_comps():
"""
This function prepares a CSV file with all published competitions
"""
+ # Set CSV file and path
+ CSV_FILE_NAME = "codabench_statistics_published_comps.csv"
+ CSV_PATH = STATISTICS_DIR + CSV_FILE_NAME
+
# Create statistics directory if not already createad
if not os.path.exists(STATISTICS_DIR):
os.makedirs(STATISTICS_DIR)
From 660ab3168ab83ea1070c8628ac8d1eb7adad7379 Mon Sep 17 00:00:00 2001
From: Ihsan Ullah
Date: Tue, 4 Feb 2025 20:39:44 +0500
Subject: [PATCH 2/5] Delete User Updates (#1740)
* Deleted user added to a separate table, Deleted user cannot create account with the same email
* auto task added to run every 24 hours to cleanup DeletedUsers
* Add notice about user deletion
---------
Co-authored-by: didayolo
---
src/apps/profiles/admin.py | 9 +++++-
.../profiles/migrations/0015_deleteduser.py | 22 +++++++++++++++
src/apps/profiles/models.py | 13 +++++++++
src/apps/profiles/tasks.py | 28 +++++++++++++++++++
src/apps/profiles/views.py | 24 ++++++++++------
src/settings/base.py | 4 +++
src/static/riot/profiles/profile_account.tag | 2 ++
.../emails/template_delete_account.html | 1 +
.../emails/template_delete_account.txt | 1 +
9 files changed, 94 insertions(+), 10 deletions(-)
create mode 100644 src/apps/profiles/migrations/0015_deleteduser.py
create mode 100644 src/apps/profiles/tasks.py
diff --git a/src/apps/profiles/admin.py b/src/apps/profiles/admin.py
index 99d23029a..b9ebbae29 100644
--- a/src/apps/profiles/admin.py
+++ b/src/apps/profiles/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
-from .models import User, Organization, Membership
+from .models import User, DeletedUser, Organization, Membership
class UserAdmin(admin.ModelAdmin):
@@ -12,7 +12,14 @@ class UserAdmin(admin.ModelAdmin):
list_display = ['username', 'email', 'is_staff', 'is_superuser']
+class DeletedUserAdmin(admin.ModelAdmin):
+ list_display = ('username', 'email', 'deleted_at')
+ search_fields = ('username', 'email')
+ list_filter = ('deleted_at',)
+
+
admin.site.register(User, UserAdmin)
+admin.site.register(DeletedUser, DeletedUserAdmin)
admin.site.register(Organization)
admin.site.register(Membership)
diff --git a/src/apps/profiles/migrations/0015_deleteduser.py b/src/apps/profiles/migrations/0015_deleteduser.py
new file mode 100644
index 000000000..39443aaae
--- /dev/null
+++ b/src/apps/profiles/migrations/0015_deleteduser.py
@@ -0,0 +1,22 @@
+# Generated by Django 2.2.28 on 2025-02-04 07:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('profiles', '0014_auto_20241120_1607'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='DeletedUser',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('username', models.CharField(max_length=255)),
+ ('email', models.EmailField(max_length=254)),
+ ('deleted_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ ]
diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py
index 991b4451f..e7131b689 100644
--- a/src/apps/profiles/models.py
+++ b/src/apps/profiles/models.py
@@ -33,6 +33,15 @@ def all_objects(self):
return super().get_queryset()
+class DeletedUser(models.Model):
+ username = models.CharField(max_length=255)
+ email = models.EmailField()
+ deleted_at = models.DateTimeField(auto_now_add=True) # Automatically sets to current time when the record is created
+
+ def __str__(self):
+ return f"{self.username} ({self.email})"
+
+
class User(ChaHubSaveMixin, AbstractBaseUser, PermissionsMixin):
# Social needs the below setting. Username is not really set to UID.
USERNAME_FIELD = 'username'
@@ -200,6 +209,7 @@ def get_used_storage_space(self):
def delete(self, *args, **kwargs):
"""Soft delete the user and anonymize personal data."""
from .views import send_user_deletion_notice_to_admin, send_user_deletion_confirmed
+ from .models import DeletedUser
# Send a notice to admins
send_user_deletion_notice_to_admin(self)
@@ -212,6 +222,9 @@ def delete(self, *args, **kwargs):
# Anonymize or removed personal data
user_email = self.email # keep track of the email for the end of the procedure
+ # Store the deleted user's data in the DeletedUser table
+ DeletedUser.objects.create(username=self.username, email=self.email)
+
# Github related
self.github_uid = None
self.avatar_url = None
diff --git a/src/apps/profiles/tasks.py b/src/apps/profiles/tasks.py
new file mode 100644
index 000000000..4dabbf2f6
--- /dev/null
+++ b/src/apps/profiles/tasks.py
@@ -0,0 +1,28 @@
+import time
+import logging
+from datetime import timedelta
+from django.utils.timezone import now
+from celery_config import app
+
+from profiles.models import DeletedUser
+
+logger = logging.getLogger()
+
+
+@app.task(queue="site-worker")
+def clean_deleted_users():
+ starting_time = time.process_time()
+ logger.info("Task clean_deleted_users Started")
+
+ # Calculate the threshold date (one month ago)
+ one_month_ago = now() - timedelta(days=30)
+
+ # Delete users who were deleted more than a month ago
+ deleted_count, _ = DeletedUser.objects.filter(deleted_at__lt=one_month_ago).delete()
+
+ logger.info(f"Deleted {deleted_count} users from DeletedUser table.")
+
+ elapsed_time = time.process_time() - starting_time
+ logger.info(
+ "Task clean_deleted_users Completed. Duration = {:.3f} seconds".format(elapsed_time)
+ )
diff --git a/src/apps/profiles/views.py b/src/apps/profiles/views.py
index 10262c699..6c3a1533b 100644
--- a/src/apps/profiles/views.py
+++ b/src/apps/profiles/views.py
@@ -20,7 +20,7 @@
from api.serializers.profiles import UserSerializer, OrganizationDetailSerializer, OrganizationEditSerializer, \
UserNotificationSerializer
from .forms import SignUpForm, LoginForm, ActivationForm
-from .models import User, Organization, Membership
+from .models import User, DeletedUser, Organization, Membership
from oidc_configurations.models import Auth_Organization
from .tokens import account_activation_token, account_deletion_token
from competitions.models import Competition
@@ -208,14 +208,20 @@ def sign_up(request):
if request.method == 'POST':
form = SignUpForm(request.POST)
if form.is_valid():
- form.save()
- 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'))
- return redirect('pages:home')
+ # Check if the email is in the DeletedUser table
+ email = form.cleaned_data.get('email')
+ 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()
+ 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'))
+ return redirect('pages:home')
else:
context['form'] = form
diff --git a/src/settings/base.py b/src/settings/base.py
index 70a8fc01e..9d492c8c7 100644
--- a/src/settings/base.py
+++ b/src/settings/base.py
@@ -236,6 +236,10 @@
'task': 'analytics.tasks.update_home_page_counters',
'schedule': timedelta(days=1), # Run every 24 hours
},
+ 'clean_deleted_users': {
+ 'task': 'profiles.tasks.clean_deleted_users',
+ 'schedule': timedelta(days=1), # Run every 24 hours
+ },
'reset_computed_storage_analytics': {
'task': 'analytics.tasks.reset_computed_storage_analytics',
'schedule': crontab(hour='2', minute='0', day_of_month='1', month_of_year="*/3") # Every 3 month at 02:00 UTC on the 1st
diff --git a/src/static/riot/profiles/profile_account.tag b/src/static/riot/profiles/profile_account.tag
index 920a55ade..6c5f7b083 100644
--- a/src/static/riot/profiles/profile_account.tag
+++ b/src/static/riot/profiles/profile_account.tag
@@ -24,6 +24,8 @@
If you wish to delete your submissions or competitions, please do so before deleting your account.
You will also no longer be eligible for any cash prizes in competitions you are participating in.
+
+ You will not be able to re-create an account using the same email address for 30 days.
diff --git a/src/templates/profiles/emails/template_delete_account.html b/src/templates/profiles/emails/template_delete_account.html
index 536567411..fce090de0 100644
--- a/src/templates/profiles/emails/template_delete_account.html
+++ b/src/templates/profiles/emails/template_delete_account.html
@@ -12,6 +12,7 @@
Once confirmed, all your personal data will be permanently deleted or anonymized, except for competitions and submissions retained under our user agreement.
After deletion, you will no longer be eligible for any cash prizes in ongoing or future competitions.
If you wish to delete any submissions, please do so before confirming your account deletion.
+ You will not be able to re-create an account using the same email address for 30 days.
diff --git a/src/templates/profiles/emails/template_delete_account.txt b/src/templates/profiles/emails/template_delete_account.txt
index 536567411..fce090de0 100644
--- a/src/templates/profiles/emails/template_delete_account.txt
+++ b/src/templates/profiles/emails/template_delete_account.txt
@@ -12,6 +12,7 @@
Once confirmed, all your personal data will be permanently deleted or anonymized, except for competitions and submissions retained under our user agreement.
After deletion, you will no longer be eligible for any cash prizes in ongoing or future competitions.
If you wish to delete any submissions, please do so before confirming your account deletion.
+ You will not be able to re-create an account using the same email address for 30 days.
From 0474b32fdba7d330a45f36498c214b552feb4e26 Mon Sep 17 00:00:00 2001
From: Ihsan Ullah
Date: Tue, 4 Feb 2025 21:44:16 +0500
Subject: [PATCH 3/5] user id added to DeletedUser modal
---
src/apps/profiles/admin.py | 2 +-
.../migrations/0016_deleteduser_user_id.py | 18 ++++++++++++++++++
src/apps/profiles/models.py | 7 ++++++-
3 files changed, 25 insertions(+), 2 deletions(-)
create mode 100644 src/apps/profiles/migrations/0016_deleteduser_user_id.py
diff --git a/src/apps/profiles/admin.py b/src/apps/profiles/admin.py
index b9ebbae29..e22e8f9ea 100644
--- a/src/apps/profiles/admin.py
+++ b/src/apps/profiles/admin.py
@@ -13,7 +13,7 @@ class UserAdmin(admin.ModelAdmin):
class DeletedUserAdmin(admin.ModelAdmin):
- list_display = ('username', 'email', 'deleted_at')
+ list_display = ('user_id', 'username', 'email', 'deleted_at')
search_fields = ('username', 'email')
list_filter = ('deleted_at',)
diff --git a/src/apps/profiles/migrations/0016_deleteduser_user_id.py b/src/apps/profiles/migrations/0016_deleteduser_user_id.py
new file mode 100644
index 000000000..e1637c9e6
--- /dev/null
+++ b/src/apps/profiles/migrations/0016_deleteduser_user_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.28 on 2025-02-04 16:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('profiles', '0015_deleteduser'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='deleteduser',
+ name='user_id',
+ field=models.IntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py
index e7131b689..7300eec86 100644
--- a/src/apps/profiles/models.py
+++ b/src/apps/profiles/models.py
@@ -34,6 +34,7 @@ def all_objects(self):
class DeletedUser(models.Model):
+ user_id = models.IntegerField(null=True, blank=True) # Store the same ID as in the User table
username = models.CharField(max_length=255)
email = models.EmailField()
deleted_at = models.DateTimeField(auto_now_add=True) # Automatically sets to current time when the record is created
@@ -223,7 +224,11 @@ def delete(self, *args, **kwargs):
user_email = self.email # keep track of the email for the end of the procedure
# Store the deleted user's data in the DeletedUser table
- DeletedUser.objects.create(username=self.username, email=self.email)
+ DeletedUser.objects.create(
+ user_id=self.id,
+ username=self.username,
+ email=self.email
+ )
# Github related
self.github_uid = None
From 5b19237d52440db65cb4ce0d67eb40621fa596df Mon Sep 17 00:00:00 2001
From: Ihsan Ullah
Date: Thu, 6 Feb 2025 21:04:41 +0500
Subject: [PATCH 4/5] Forum Updates - restrictions added for non-participants
(#1743)
* forum restrictions added
* access fixed, back button enabled when no access to forum
* Add return button when not logged in
---------
Co-authored-by: didayolo
---
src/apps/forums/views.py | 46 ++++++++++--
src/templates/forums/base_forum.html | 107 ++++++++++++++++++---------
2 files changed, 111 insertions(+), 42 deletions(-)
diff --git a/src/apps/forums/views.py b/src/apps/forums/views.py
index 39f80ce3b..d74d59509 100644
--- a/src/apps/forums/views.py
+++ b/src/apps/forums/views.py
@@ -1,16 +1,18 @@
import datetime
+from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect, Http404
-from django.shortcuts import get_object_or_404
+from django.shortcuts import get_object_or_404, redirect
from django.utils.timezone import now
from django.views.generic import DetailView, CreateView, DeleteView
from .forms import PostForm, ThreadForm
from .models import Forum, Thread, Post
+from competitions.models import CompetitionParticipant
User = get_user_model()
@@ -26,16 +28,31 @@ def dispatch(self, *args, **kwargs):
self.forum = get_object_or_404(Forum, pk=self.kwargs['forum_pk'])
if 'thread_pk' in self.kwargs:
self.thread = get_object_or_404(Thread, pk=self.kwargs['thread_pk'])
+
+ # Determine if the user is a participant and store it as an instance variable
+ self.is_participant = self.is_user_participant(self.request.user, self.forum)
+
return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['forum'] = self.forum
context['thread'] = self.thread if hasattr(self, 'thread') else None
+ context['is_participant'] = self.is_participant
return context
+ def is_user_participant(self, user, forum):
+ is_participant = False
+ if user.is_authenticated:
+ is_participant = CompetitionParticipant.objects.filter(
+ competition=forum.competition,
+ user=user,
+ status=CompetitionParticipant.APPROVED
+ ).exists()
+ return is_participant
+
-class ForumDetailView(DetailView):
+class ForumDetailView(ForumBaseMixin, DetailView):
"""
Shows the details of a particular Forum.
"""
@@ -45,9 +62,15 @@ class ForumDetailView(DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- context['thread_list_sorted'] = self.object.threads.order_by('pinned_date', '-date_created')\
- .select_related('forum', 'forum__competition', 'forum__competition__created_by', 'started_by')\
- .prefetch_related('forum__competition__collaborators', 'posts')
+
+ context['thread_list_sorted'] = self.object.threads.order_by(
+ 'pinned_date', '-date_created'
+ ).select_related(
+ 'forum', 'forum__competition', 'forum__competition__created_by', 'started_by'
+ ).prefetch_related(
+ 'forum__competition__collaborators', 'posts'
+ )
+
return context
@@ -66,6 +89,12 @@ class CreatePostView(ForumBaseMixin, RedirectToThreadMixin, LoginRequiredMixin,
form_class = PostForm
def form_valid(self, form):
+
+ if not self.is_participant:
+ messages.error(self.request, "You must be a participant of this competition to create a post.")
+ return redirect("forums:forum_thread_detail", forum_pk=self.forum.pk, thread_pk=self.thread.pk)
+
+ # Create the post since the user is a participant
self.post = form.save(commit=False)
self.post.thread = self.thread
self.post.posted_by = self.request.user
@@ -106,6 +135,13 @@ class CreateThreadView(ForumBaseMixin, RedirectToThreadMixin, LoginRequiredMixin
form_class = ThreadForm
def form_valid(self, form):
+
+ if not self.is_participant:
+ messages.error(self.request, "You must be a participant of this competition to create a thread.")
+ return redirect("forums:forum_detail", forum_pk=self.forum.pk)
+
+ # Create the thread since the user is a participant
+ self.thread = form.save(commit=False)
self.thread = form.save(commit=False)
self.thread.forum = self.forum
self.thread.started_by = self.request.user
diff --git a/src/templates/forums/base_forum.html b/src/templates/forums/base_forum.html
index 779f533d6..607469eec 100644
--- a/src/templates/forums/base_forum.html
+++ b/src/templates/forums/base_forum.html
@@ -9,50 +9,83 @@
{% block content %}
-
-
-
- {% if forum.competition.contact_email %}
-
-
{% endif %}
-
-
-
-
- {% block forum_content %}
- {% endblock forum_content %}
-
-
-
diff --git a/src/utils/data.py b/src/utils/data.py
index d7b7669c7..b321d0a6a 100644
--- a/src/utils/data.py
+++ b/src/utils/data.py
@@ -121,9 +121,13 @@ def put_blob(url, file_path):
)
-def pretty_bytes(bytes, decimal_places=1, suffix="B"):
- for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']:
- if abs(bytes) < 1024.0 or unit == 'PiB':
- return f"{bytes:3.{decimal_places}f}{unit}{suffix}"
- bytes /= 1024.0
- return f"{bytes:.{decimal_places}f}Pi{suffix}"
+def pretty_bytes(bytes, decimal_places=1, suffix="B", binary=False):
+ factor = 1024.0 if binary else 1000.0
+ units = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi'] if binary else ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z']
+
+ for unit in units:
+ if abs(bytes) < factor or unit == (units[-1] + "B"):
+ return f"{bytes:.{decimal_places}f}{unit}{suffix}"
+ bytes /= factor
+
+ return f"{bytes:.{decimal_places}f}{units[-1]}{suffix}"