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 @@

{{ contributor.first_name }}


{% endif %} + {% if has_badges %} +
+
+

Achievements

+ {% for path, badge in badges.items() %} + {% if badge.level %} +
+ + {% if badge.level > 1 %} +
{{ badge.level }}x
+ {% endif %} +
+ {% endif %} + {% endfor %} +
+
+
+ {% endif %} +

Latest activity

diff --git a/pontoon/contributors/views.py b/pontoon/contributors/views.py index 817e58a510..fd65f1ea37 100644 --- a/pontoon/contributors/views.py +++ b/pontoon/contributors/views.py @@ -26,7 +26,11 @@ from pontoon.base.models import Locale, Project, UserProfile from pontoon.base.utils import get_locale_or_redirect, require_AJAX from pontoon.contributors import utils -from pontoon.settings import VIEW_CACHE_TIMEOUT +from pontoon.settings import ( + BADGES_PROMOTION_THRESHOLDS, + BADGES_TRANSLATION_THRESHOLDS, + VIEW_CACHE_TIMEOUT, +) from pontoon.uxactionlog.utils import log_ux_action @@ -44,6 +48,13 @@ def contributor_email(request, email): return contributor(request, user) +def get_badge_level(thresholds, count): + for level, threshold in enumerate(thresholds): + if count < threshold: + return level + return len(thresholds) + + def contributor_username(request, username): try: user = User.objects.get(username=username) @@ -72,6 +83,26 @@ def contributor(request, user): "contact_for": user.contact_for.filter( disabled=False, system_project=False, visibility="public" ).order_by("-priority"), + "badges": { + "review_master_badge": { + "level": get_badge_level( + BADGES_TRANSLATION_THRESHOLDS, user.badges_review_count + ), + "name": "Review Master", + }, + "translation_champion_badge": { + "level": get_badge_level( + BADGES_TRANSLATION_THRESHOLDS, user.badges_translation_count + ), + "name": "Translation Champion", + }, + "community_builder_badge": { + "level": get_badge_level( + BADGES_PROMOTION_THRESHOLDS, user.badges_promotion_count + ), + "name": "Community Builder", + }, + }, "all_time_stats": { "translations": user.contributed_translations, }, diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index 310bac7a5f..2dbd3b2bf2 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -5,10 +5,12 @@ import re import socket +from datetime import datetime from ipaddress import ip_address, ip_network import dj_database_url +from django.utils import timezone from django.utils.functional import lazy @@ -1111,4 +1113,25 @@ def account_username(user): # management command will run. SUGGESTION_NOTIFICATIONS_DAY = os.environ.get("SUGGESTION_NOTIFICATIONS_DAY", 4) +# Date from which badge data collection starts +badges_start_date = os.environ.get("BADGES_START_DATE", "1970-01-01") +try: + BADGES_START_DATE = timezone.make_aware( + datetime.strptime(badges_start_date, "%Y-%m-%d"), timezone=timezone.utc + ) +except ValueError as e: + raise ValueError(f"Error: {e}") + +# Used for Review Master and Translation Champion +BADGES_TRANSLATION_THRESHOLDS = list( + map( + int, + os.environ.get("BADGES_TRANSLATION_THRESHOLDS", "5, 50, 250, 1000").split(","), + ) +) +# Used for Community Builder +BADGES_PROMOTION_THRESHOLDS = list( + map(int, os.environ.get("BADGES_PROMOTION_THRESHOLDS", "1, 2, 5").split(",")) +) + DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/pontoon/translations/views.py b/pontoon/translations/views.py index 4523b9419a..d717145806 100644 --- a/pontoon/translations/views.py +++ b/pontoon/translations/views.py @@ -1,5 +1,6 @@ from notifications.signals import notify +from django.conf import settings from django.contrib.auth.decorators import login_required from django.db import transaction from django.http import JsonResponse @@ -169,6 +170,11 @@ def create_translation(request): description=desc, ) + # Award Translation Champion Badge stats + if user.badges_translation_count in settings.BADGES_TRANSLATION_THRESHOLDS: + # TODO: Send a notification to the user + pass + return JsonResponse( { "status": True, @@ -309,6 +315,11 @@ def approve_translation(request): plural_form=translation.plural_form, ) + # Reward Review Master Badge stats + if user.badges_review_count in settings.BADGES_TRANSLATION_THRESHOLDS: + # TODO: Send a notification to the user + pass + return JsonResponse( { "translation": active_translation.serialize(), @@ -439,6 +450,11 @@ def reject_translation(request): plural_form=translation.plural_form, ) + # Reward Review Master Badge stats + if request.user.badges_review_count in settings.BADGES_TRANSLATION_THRESHOLDS: + # TODO: Send a notification to the user + pass + return JsonResponse( { "translation": active_translation.serialize(),