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 %}
    -
    -
    -

    {{ forum.competition.title }} Forum

    - - - - {% if thread or 'new_thread' in request.path %} - - - + {% if user.is_authenticated %} + {% if is_participant %} +
    +
    +

    {{ forum.competition.title }} Forum

    + + + + {% if thread or 'new_thread' in request.path %} + + + + {% endif %} + + {% if not thread %} + + + + {% endif %} +
    +
    + + {% if forum.competition.contact_email %} +
    +
    + Contact Email: + {{ forum.competition.contact_email }} + +
    +
    {% endif %} - {% if not thread %} - - + +
    +
    +
    +

    To participate in the forum, send a registration request to the competition.

    +
    + {% endif %} + {% else %} +
    +
    +

    {{ forum.competition.title }} Forum

    + + - {% endif %} +
    -
    - - {% if forum.competition.contact_email %} -
    -
    - Contact Email: - {{ forum.competition.contact_email }} - +
    + Log In or + Sign Up to view this competition forum.
    -
    {% 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}"