Skip to content

Commit

Permalink
Add notifications when a badge is awarded (#3442)
Browse files Browse the repository at this point in the history
Send notification and open a pop-up (feat. a confetti animation!) when badge gets awarded. 

Also included:
* New `ActionLog.is_implicit_action` field. Some action types (e.g. `TRANSLATION_CREATED`) may trigger other actions (e.g. `TRANSLATION_REJECTED`) without direct user intervention. The latter actions now have `is_implicit_action` set to `True`. That allows us to only include explicit actions in badge counts.
  • Loading branch information
harmitgoswami authored Dec 11, 2024
1 parent eb65cee commit 1ea8f48
Show file tree
Hide file tree
Showing 32 changed files with 1,557 additions and 118 deletions.
6 changes: 5 additions & 1 deletion docs/admin/deployment.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,17 @@ you create:
Optional. A comma-separated list of numeric thresholds for different levels of the
Community Builder badge.

``BADGES_REVIEW_THRESHOLDS``
Optional. A comma-separated list of numeric thresholds for different levels of the
Review Master badge.

``BADGES_START_DATE``
Optional. Specifies the start date from which user activities count towards badge achievements.
This variable should be in YYYY-MM-DD format.

``BADGES_TRANSLATION_THRESHOLDS``
Optional. A comma-separated list of numeric thresholds for different levels of the
Review Master and Translation Champion badges.
Translation Champion badge.

``BLOCKED_IPS``
A comma-separated list of IP addresses or IP ranges (expressed using the
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default [
jQuery: 'readonly',
Clipboard: 'readonly',
Chart: 'readonly',
confetti: 'readonly',
NProgress: 'readonly',
diff_match_patch: 'readonly',
Highcharts: 'readonly',
Expand Down
30 changes: 30 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions pontoon/actionlog/migrations/0007_actionlog_is_implicit_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.16 on 2024-12-10 19:39

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("actionlog", "0006_actionlog_tm_entries_alter_actionlog_action_type"),
]

operations = [
migrations.AddField(
model_name="actionlog",
name="is_implicit_action",
field=models.BooleanField(default=False),
),
]
18 changes: 18 additions & 0 deletions pontoon/actionlog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ class ActionType(models.TextChoices):
blank=True,
)

# Some action types (e.g. TRANSLATION_CREATED) may trigger other actions
# (e.g. TRANSLATION_REJECTED) without direct user intervention.
# The latter actions should have `is_implicit_action` set to `True`.
is_implicit_action = models.BooleanField(default=False)

def validate_action_type_choice(self):
valid_types = self.ActionType.values
if self.action_type not in valid_types:
Expand All @@ -69,6 +74,18 @@ def validate_action_type_choice(self):
)
)

def validate_implicit_action_type_choice(self):
valid_types = [
self.ActionType.TRANSLATION_UNAPPROVED,
self.ActionType.TRANSLATION_REJECTED,
]
if self.is_implicit_action and self.action_type not in valid_types:
raise ValidationError(
'Action type "{}" is not one of the permitted values for an implicit action: {}'.format(
self.action_type, ", ".join(valid_types)
)
)

def validate_foreign_keys_per_action(self):
if self.action_type in (
self.ActionType.TRANSLATION_DELETED,
Expand Down Expand Up @@ -121,6 +138,7 @@ def validate_many_to_many_relationships_per_action(self):

def save(self, *args, **kwargs):
self.validate_action_type_choice()
self.validate_implicit_action_type_choice()
self.validate_foreign_keys_per_action()

super().save(*args, **kwargs)
Expand Down
2 changes: 2 additions & 0 deletions pontoon/actionlog/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
def log_action(
action_type,
user,
is_implicit_action=False,
translation=None,
entity=None,
locale=None,
Expand Down Expand Up @@ -32,6 +33,7 @@ def log_action(
translation=translation,
entity=entity,
locale=locale,
is_implicit_action=is_implicit_action,
)
action.save()

Expand Down
56 changes: 23 additions & 33 deletions pontoon/base/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,15 @@
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone

from pontoon.base import utils
from pontoon.base.models import (
Locale,
PermissionChangelog,
ProjectLocale,
User,
UserProfile,
)
from pontoon.messaging.notifications import send_badge_notification
from pontoon.sync.formats import are_compatible_files
from pontoon.teams.utils import log_group_members

Expand Down Expand Up @@ -91,6 +90,8 @@ class UserPermissionLogFormMixin:
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
# Track if user reached new level for Community Builder Badge
self.community_builder_level_reached = 0

def assign_users_to_groups(self, group_name, users):
"""
Expand All @@ -102,42 +103,11 @@ 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(
Expand All @@ -158,9 +128,29 @@ def save(self, *args, **kwargs):
translators = self.cleaned_data.get("translators", User.objects.none())
managers = self.cleaned_data.get("managers", User.objects.none())

before_count = self.user.badges_promotion_count

self.assign_users_to_groups("translators", translators)
self.assign_users_to_groups("managers", managers)

after_count = self.user.badges_promotion_count

# Award Community Builder badge
if (
after_count > before_count
and after_count in settings.BADGES_PROMOTION_THRESHOLDS
):
self.community_builder_level_reached = (
settings.BADGES_PROMOTION_THRESHOLDS.index(after_count) + 1
)
send_badge_notification(
self.user,
"Community Builder Badge",
self.community_builder_level_reached,
)

return self.community_builder_level_reached


class ProjectLocalePermsForm(UserPermissionLogFormMixin, forms.ModelForm):
translators = forms.ModelMultipleChoiceField(
Expand Down
1 change: 1 addition & 0 deletions pontoon/base/models/translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ def save(self, update_stats=True, failed_checks=None, *args, **kwargs):
ActionLog.ActionType.TRANSLATION_REJECTED,
self.approved_user or self.user,
translation=t,
is_implicit_action=True,
)

# Remove any TM entries of old translations that will get rejected.
Expand Down
32 changes: 29 additions & 3 deletions pontoon/base/models/user.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import timedelta
from hashlib import md5
from urllib.parse import quote, urlencode

Expand All @@ -7,7 +8,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, Q
from django.db.models import Count, Exists, OuterRef, Q
from django.urls import reverse
from django.utils import timezone

Expand Down Expand Up @@ -237,16 +238,41 @@ def badges_review_count(self):
return self.actions.filter(
Q(action_type="translation:approved") | Q(action_type="translation:rejected"),
created_at__gte=settings.BADGES_START_DATE,
is_implicit_action=False,
).count()


@property
def badges_promotion_count(self):
"""Role promotions performed by user that count towards their badges"""
return self.changed_permissions_log.filter(
added_entries = self.changed_permissions_log.filter(
action_type="added",
created_at__gte=settings.BADGES_START_DATE,
).count()
)

# Check if user was demoted from Manager to Translator.
# In this case, it doesn't count as a promotion.
#
# TODO:
# This code is the only consumer of the PermissionChangelog model, so we should
# refactor to simplify how promotions are retrieved. (see #2195)
return (
added_entries.exclude(
Exists(
self.changed_permissions_log.filter(
performed_by=OuterRef("performed_by"),
performed_on=OuterRef("performed_on"),
action_type="removed",
created_at__gt=OuterRef("created_at"),
created_at__lte=OuterRef("created_at") + timedelta(milliseconds=10),
)
)
)
.order_by("performed_on", "group")
# Only count promotion of each user to the same group once
.distinct("performed_on", "group")
.count()
)


@property
Expand Down
Loading

0 comments on commit 1ea8f48

Please sign in to comment.