diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index 252a3386b2..5bd8d16d2f 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -4,10 +4,12 @@ from django import forms from django.conf import settings +from django.utils import timezone from pontoon.base import utils from pontoon.base.models import ( Locale, + PermissionChangelog, ProjectLocale, User, UserProfile, @@ -97,11 +99,42 @@ def assign_users_to_groups(self, group_name, users): group.user_set.clear() + before_count = self.user.badges_promotion_count + now = timezone.now() + if users: group.user_set.add(*users) log_group_members(self.user, group, (add_users, remove_users)) + after_count = self.user.badges_promotion_count + + # TODO: + # This code is the only consumer of the PermissionChangelog + # model, so we should refactor in the future to simplify + # how promotions are retrieved. (see #2195) + + # Check if user was demoted from Manager to Translator + # In this case, it doesn't count as a promotion + if group_name == "managers": + removal = PermissionChangelog.objects.filter( + performed_by=self.user, + action_type=PermissionChangelog.ActionType.REMOVED, + created_at__gte=now, + ) + if removal: + for item in removal: + if "managers" in item.group.name: + after_count -= 1 + + # Award Community Builder badge + if ( + after_count > before_count + and after_count in settings.BADGES_PROMOTION_THRESHOLDS + ): + # TODO: Send a notification to the user + pass + class LocalePermsForm(UserPermissionLogFormMixin, forms.ModelForm): translators = forms.ModelMultipleChoiceField( diff --git a/pontoon/base/migrations/0067_remove_userprofile_community_builder_level_and_more.py b/pontoon/base/migrations/0067_remove_userprofile_community_builder_level_and_more.py new file mode 100644 index 0000000000..f771a93218 --- /dev/null +++ b/pontoon/base/migrations/0067_remove_userprofile_community_builder_level_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.16 on 2024-10-17 17:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("base", "0066_userprofile_community_builder_level_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="userprofile", + name="community_builder_level", + ), + migrations.RemoveField( + model_name="userprofile", + name="review_master_level", + ), + migrations.RemoveField( + model_name="userprofile", + name="translation_champion_level", + ), + ] diff --git a/pontoon/base/models/user.py b/pontoon/base/models/user.py index d8d674c6a3..68fe17f503 100644 --- a/pontoon/base/models/user.py +++ b/pontoon/base/models/user.py @@ -7,7 +7,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.db.models import Count +from django.db.models import Count, Q from django.urls import reverse from django.utils import timezone @@ -220,6 +220,33 @@ def has_approved_translations(self): return self.translation_set.filter(approved=True).exists() +@property +def badges_translation_count(self): + """Contributions provided by user that count towards their badges.""" + return self.actions.filter( + action_type="translation:created", + created_at__gte=settings.BADGES_START_DATE, + ).count() + + +@property +def badges_review_count(self): + """Translation reviews provided by user that count towards their badges.""" + return self.actions.filter( + Q(action_type="translation:approved") | Q(action_type="translation:rejected"), + created_at__gte=settings.BADGES_START_DATE, + ).count() + + +@property +def badges_promotion_count(self): + """Role promotions performed by user that count towards their badges""" + return self.changed_permissions_log.filter( + action_type="added", + created_at__gte=settings.BADGES_START_DATE, + ).count() + + @property def top_contributed_locale(self): """Locale the user has made the most contributions to.""" @@ -445,6 +472,9 @@ def latest_action(self): User.add_to_class("locale_role", user_locale_role) User.add_to_class("status", user_status) User.add_to_class("contributed_translations", contributed_translations) +User.add_to_class("badges_translation_count", badges_translation_count) +User.add_to_class("badges_review_count", badges_review_count) +User.add_to_class("badges_promotion_count", badges_promotion_count) User.add_to_class("has_approved_translations", has_approved_translations) User.add_to_class("top_contributed_locale", top_contributed_locale) User.add_to_class("can_translate", can_translate) diff --git a/pontoon/base/models/user_profile.py b/pontoon/base/models/user_profile.py index 627b5086e4..4f1a7b3048 100644 --- a/pontoon/base/models/user_profile.py +++ b/pontoon/base/models/user_profile.py @@ -37,46 +37,6 @@ class Themes(models.TextChoices): default=Themes.DARK, ) - ## Badges ## - - # Review Master - review_master_level = models.IntegerField( - default=0, - choices=[ - (0, "No Badge"), - (1, "Review Master Level 1"), # Review 5 translations - (2, "Review Master Level 2"), # 50 translations - (3, "Review Master Level 3"), # 250 translations - (4, "Review Master Level 4"), # 1000 translations - ], - ) - - # Translation Champion - translation_champion_level = models.IntegerField( - default=0, - choices=[ - (0, "No Badge"), - (1, "Translation Champion Level 1"), # Submit 5 translations - (2, "Translation Champion Level 2"), # 50 translations - (3, "Translation Champion Level 3"), # 250 translations - (4, "Translation Champion Level 4"), # 1000 translations - ], - ) - - # Community Builder - community_builder_level = models.IntegerField( - default=0, - choices=[ - (0, "No Badge"), - ( - 1, - "Community Builder Level 1", - ), # Successfully promote 1 contributor to a new role - (2, "Community Builder Level 2"), # 2 contributors - (3, "Community Builder Level 3"), # 5 contributors - ], - ) - # External accounts chat = models.CharField("Chat username", max_length=255, blank=True, null=True) github = models.CharField("GitHub username", max_length=255, blank=True, null=True) diff --git a/pontoon/contributors/static/css/profile.css b/pontoon/contributors/static/css/profile.css index 4c08533657..a09cf8f9df 100644 --- a/pontoon/contributors/static/css/profile.css +++ b/pontoon/contributors/static/css/profile.css @@ -119,6 +119,35 @@ h2 { width: 100%; } +.achievements .block { + font-size: 0; +} + +.achievements .badge-wrapper { + position: relative; + display: inline-block; + margin: 10px 7px 0 0; +} + +.achievements .badge-wrapper:last-child { + margin-right: 0; +} + +.achievements img.badge { + border: 2px solid var(--main-border-1); + border-radius: 50%; +} + +.achievements .badge-level { + position: absolute; + bottom: 5px; + left: 2px; + background: var(--icon-background-1); + padding: 2px 4px; + border-radius: 4px; + font-size: 12px; +} + h4 { font-size: 14px; } diff --git a/pontoon/contributors/static/img/community_builder_badge.svg b/pontoon/contributors/static/img/community_builder_badge.svg new file mode 100644 index 0000000000..fb8d3dc42b --- /dev/null +++ b/pontoon/contributors/static/img/community_builder_badge.svg @@ -0,0 +1,11 @@ + diff --git a/pontoon/contributors/static/img/review_master_badge.svg b/pontoon/contributors/static/img/review_master_badge.svg new file mode 100644 index 0000000000..9188f95895 --- /dev/null +++ b/pontoon/contributors/static/img/review_master_badge.svg @@ -0,0 +1,6 @@ + diff --git a/pontoon/contributors/static/img/translation_champion_badge.svg b/pontoon/contributors/static/img/translation_champion_badge.svg new file mode 100644 index 0000000000..85d50d3830 --- /dev/null +++ b/pontoon/contributors/static/img/translation_champion_badge.svg @@ -0,0 +1,10 @@ + diff --git a/pontoon/contributors/templates/contributors/profile.html b/pontoon/contributors/templates/contributors/profile.html index 2281135f1b..a31ee2736e 100644 --- a/pontoon/contributors/templates/contributors/profile.html +++ b/pontoon/contributors/templates/contributors/profile.html @@ -22,6 +22,7 @@ {% set is_my_profile = (user.is_authenticated and user.email == contributor.email) %} {% set profile = contributor.profile %} + {% set has_badges = badges.review_master_badge.level or badges.translation_champion_badge.level or badges.community_builder_badge.level %} {% set translator_for_locales = contributor.translator_for_locales %} {% set manager_for_locales = contributor.manager_for_locales %} {% set user_is_translator = user.translated_locales %} @@ -121,6 +122,25 @@