Repository: mozilla/pontoon

Issue:
Implement contribution graph
Reference: [specs](https://github.com/mozilla/pontoon/blob/master/specs/0111-redesign-profile-page.md)

This issue tracks the implementation of the contribution graph.

Blocked by:
* #2485
* #2486


Discussion:
We should consider #2138 when implementing the bottom part of the widget that displays detailed data of the selected time range. We should be able to reuse logic, data model or UI.
Adding a link to the old and now obsolete proposal for the [review dashboard](https://docs.google.com/document/d/18fuv9B33LLrL0U2MUbZpGGmlZj4-QR-fX4fDIHSrtTM/edit#heading=h.jgclo7dnn5bo). It has some nice ideas (like diff) and I like that it also includes comments.

If we want the "Reviews received" view to serve as the review report, it must also include comments. I thought it's worth mentioning here since we didn't mention it in the spec.

Resolving patch:
diff --git a/pontoon/contributors/urls.py b/pontoon/contributors/urls.py
--- a/pontoon/contributors/urls.py
+++ b/pontoon/contributors/urls.py
@@ -86,4 +86,10 @@ class UsernameConverter(StringConverter):
         views.dismiss_addon_promotion,
         name="pontoon.contributors.dismiss_addon_promotion",
     ),
+    # AJAX: Update contribution graph
+    path(
+        "update-contribution-graph/",
+        views.update_contribution_graph,
+        name="pontoon.contributors.update_contribution_graph",
+    ),
 ]
diff --git a/pontoon/contributors/utils.py b/pontoon/contributors/utils.py
--- a/pontoon/contributors/utils.py
+++ b/pontoon/contributors/utils.py
@@ -14,7 +14,8 @@
     Prefetch,
     Q,
 )
-from django.db.models.functions import TruncMonth
+from django.db.models.functions import TruncDay, TruncMonth
+from django.template.defaultfilters import pluralize
 from django.template.loader import get_template
 from django.urls import reverse
 from django.utils import timezone
@@ -24,6 +25,7 @@
     Locale,
     Translation,
 )
+from pontoon.base.templatetags.helpers import intcomma
 from pontoon.base.utils import convert_to_unix_time
 
 
@@ -287,3 +289,68 @@ def get_approval_rates(user):
         "self_approval_rates": self_approval_rates[-12:],
         "self_approval_rates_12_month_avg": self_approval_rates_12_month_avg,
     }
+
+
+def get_daily_action_counts(qs):
+    return {
+        convert_to_unix_time(item["timestamp"]): item["count"]
+        for item in (
+            qs.annotate(timestamp=TruncDay("created_at"))
+            .values("timestamp")
+            .annotate(count=Count("id"))
+            .values("timestamp", "count")
+        )
+    }
+
+
+def get_contributions(user, contribution_type=None):
+    """
+    Get data required to render the Contribution graph on the Profile page
+    """
+    actions = ActionLog.objects.filter(
+        created_at__gte=timezone.now() - relativedelta(days=365),
+    )
+
+    review_action_types = [
+        ActionLog.ActionType.TRANSLATION_APPROVED,
+        ActionLog.ActionType.TRANSLATION_REJECTED,
+    ]
+
+    user_translations = actions.filter(
+        performed_by=user, action_type=ActionLog.ActionType.TRANSLATION_CREATED
+    )
+    user_reviews = actions.filter(
+        performed_by=user, action_type__in=review_action_types
+    )
+    peer_reviews = actions.filter(
+        translation__user=user, action_type__in=review_action_types
+    )
+
+    all_user_contributions = user_translations | user_reviews
+
+    # Using the union of all_user_contributions and peer_reviews QuerySets results in poorer performance
+    all_contributions = ActionLog.objects.filter(
+        pk__in=(
+            list(all_user_contributions.values_list("pk", flat=True))
+            + list(peer_reviews.values_list("pk", flat=True))
+        )
+    )
+
+    action_map = {
+        "user_translations": user_translations,
+        "user_reviews": user_reviews,
+        "peer_reviews": peer_reviews,
+        "all_user_contributions": all_user_contributions,
+        "all_contributions": all_contributions,
+    }
+
+    if contribution_type not in action_map.keys():
+        contribution_type = "all_user_contributions"
+
+    contributions = get_daily_action_counts(action_map[contribution_type])
+    total = sum(contributions.values())
+
+    return (
+        contributions,
+        f"{ intcomma(total) } contribution{ pluralize(total) } in the last year",
+    )
diff --git a/pontoon/contributors/views.py b/pontoon/contributors/views.py
--- a/pontoon/contributors/views.py
+++ b/pontoon/contributors/views.py
@@ -51,15 +51,18 @@ def contributor_username(request, username):
 
 def contributor(request, user):
     """Contributor profile."""
-    context = utils.get_approval_rates(user)
+    contributions, title = utils.get_contributions(user)
 
+    context = utils.get_approval_rates(user)
     context.update(
         {
+            "title": title,
             "contributor": user,
             "translations": user.contributed_translations,
             "contact_for": user.contact_for.filter(
                 disabled=False, system_project=False, visibility="public"
             ).order_by("-priority"),
+            "contributions": json.dumps(contributions),
         }
     )
 
@@ -70,6 +73,21 @@ def contributor(request, user):
     )
 
 
+def update_contribution_graph(request):
+    """Contributor profile."""
+    try:
+        user = User.objects.get(pk=request.GET["user"])
+        contribution_type = request.GET["contribution_type"]
+    except User.DoesNotExist as e:
+        return JsonResponse(
+            {"status": False, "message": f"Bad Request: {e}"},
+            status=400,
+        )
+
+    contributions, title = utils.get_contributions(user, contribution_type)
+    return JsonResponse({"contributions": contributions, "title": title})
+
+
 @login_required(redirect_field_name="", login_url="/403")
 @require_POST
 @transaction.atomic


Test patch:
diff --git a/pontoon/contributors/tests/test_utils.py b/pontoon/contributors/tests/test_utils.py
--- a/pontoon/contributors/tests/test_utils.py
+++ b/pontoon/contributors/tests/test_utils.py
@@ -11,6 +11,72 @@
 from pontoon.contributors import utils
 
 
+@pytest.fixture
+def months_a():
+    return [
+        convert_to_unix_time(datetime(2020, 1, 1)),
+        convert_to_unix_time(datetime(2020, 2, 1)),
+        convert_to_unix_time(datetime(2020, 3, 1)),
+    ]
+
+
+@pytest.fixture
+def action_a(translation_a):
+    action = ActionLog.objects.create(
+        action_type=ActionLog.ActionType.TRANSLATION_CREATED,
+        translation=translation_a,
+    )
+    action.created_at = datetime(2020, 1, 1)
+    action.save()
+    return action
+
+
+@pytest.fixture
+def action_b(translation_a):
+    action = ActionLog.objects.create(
+        action_type=ActionLog.ActionType.TRANSLATION_CREATED,
+        translation=translation_a,
+    )
+    action.created_at = datetime(2020, 1, 1)
+    action.save()
+    return action
+
+
+@pytest.fixture
+def action_c(translation_a):
+    action = ActionLog.objects.create(
+        action_type=ActionLog.ActionType.TRANSLATION_CREATED,
+        translation=translation_a,
+    )
+    action.created_at = datetime(2020, 2, 1)
+    action.save()
+    return action
+
+
+@pytest.fixture
+def action_user_a(translation_a, user_a):
+    action = ActionLog.objects.create(
+        action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
+        performed_by=user_a,
+        translation=translation_a,
+    )
+    action.created_at = datetime.now() - relativedelta(months=1)
+    action.save()
+    return action
+
+
+@pytest.fixture
+def action_user_b(translation_a, user_b):
+    action = ActionLog.objects.create(
+        action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
+        performed_by=user_b,
+        translation=translation_a,
+    )
+    action.created_at = datetime.now()
+    action.save()
+    return action
+
+
 @pytest.mark.django_db
 def test_generate_verification_token(member):
     with patch("jwt.encode") as mock_encode:
@@ -72,41 +138,17 @@ def test_get_n_months_before():
 
 
 @pytest.mark.django_db
-def test_get_monthly_action_counts(translation_a):
-    months = [
-        convert_to_unix_time(datetime(2020, 1, 1)),
-        convert_to_unix_time(datetime(2020, 2, 1)),
-        convert_to_unix_time(datetime(2020, 3, 1)),
-    ]
-
-    # No actions
+def test_get_monthly_action_counts_without_actions(months_a):
     actions_qs = ActionLog.objects.filter()
-    assert utils.get_monthly_action_counts(months, actions_qs) == [0, 0, 0]
-
-    # Some actions
-    action1 = ActionLog.objects.create(
-        action_type=ActionLog.ActionType.TRANSLATION_CREATED,
-        translation=translation_a,
-    )
-    action1.created_at = datetime(2020, 1, 1)
-    action1.save()
+    assert utils.get_monthly_action_counts(months_a, actions_qs) == [0, 0, 0]
 
-    action2 = ActionLog.objects.create(
-        action_type=ActionLog.ActionType.TRANSLATION_CREATED,
-        translation=translation_a,
-    )
-    action2.created_at = datetime(2020, 1, 1)
-    action2.save()
 
-    action3 = ActionLog.objects.create(
-        action_type=ActionLog.ActionType.TRANSLATION_CREATED,
-        translation=translation_a,
+@pytest.mark.django_db
+def test_get_monthly_action_counts_with_actions(months_a, action_a, action_b, action_c):
+    actions_qs = ActionLog.objects.filter(
+        pk__in=[action_a.pk, action_b.pk, action_c.pk]
     )
-    action3.created_at = datetime(2020, 2, 1)
-    action3.save()
-
-    actions_qs = ActionLog.objects.filter(pk__in=[action1.pk, action2.pk, action3.pk])
-    assert utils.get_monthly_action_counts(months, actions_qs) == [2, 1, 0]
+    assert utils.get_monthly_action_counts(months_a, actions_qs) == [2, 1, 0]
 
 
 def test_get_shares_of_totals():
@@ -120,8 +162,7 @@ def test_get_sublist_averages():
 
 
 @pytest.mark.django_db
-def test_get_approval_rates(user_a, user_b, translation_a):
-    # User without any contributions
+def test_get_approval_rates_without_actions(user_a):
     data = utils.get_approval_rates(user_a)
 
     assert data["approval_rates"] == [0] * 12
@@ -129,23 +170,9 @@ def test_get_approval_rates(user_a, user_b, translation_a):
     assert data["self_approval_rates"] == [0] * 12
     assert data["self_approval_rates_12_month_avg"] == [0] * 12
 
-    # User with some contributions
-    action1 = ActionLog.objects.create(
-        action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
-        performed_by=user_b,
-        translation=translation_a,
-    )
-    action1.created_at = datetime.now()
-    action1.save()
-
-    action2 = ActionLog.objects.create(
-        action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
-        performed_by=user_a,
-        translation=translation_a,
-    )
-    action2.created_at = datetime.now() - relativedelta(months=1)
-    action2.save()
 
+@pytest.mark.django_db
+def test_get_approval_rates_with_actions(user_a, action_user_a, action_user_b):
     data = utils.get_approval_rates(user_a)
 
     assert data["approval_rates"] == [0] * 11 + [100]
@@ -154,3 +181,34 @@ def test_get_approval_rates(user_a, user_b, translation_a):
     assert (
         data["self_approval_rates_12_month_avg"] == [0] * 10 + [8.333333333333334] * 2
     )
+
+
+@pytest.mark.django_db
+def test_get_daily_action_counts_without_actions():
+    actions_qs = ActionLog.objects.filter()
+    assert utils.get_daily_action_counts(actions_qs) == {}
+
+
+@pytest.mark.django_db
+def test_get_daily_action_counts_with_actions(action_a, action_b, action_c):
+    actions_qs = ActionLog.objects.filter(
+        pk__in=[action_a.pk, action_b.pk, action_c.pk]
+    )
+    assert utils.get_daily_action_counts(actions_qs) == {
+        convert_to_unix_time(datetime(2020, 1, 1)): 2,
+        convert_to_unix_time(datetime(2020, 2, 1)): 1,
+    }
+
+
+@pytest.mark.django_db
+def test_get_contributions_without_actions(user_a):
+    assert utils.get_contributions(user_a) == (
+        {},
+        "0 contributions in the last year",
+    )
+
+
+@pytest.mark.django_db
+def test_get_contributions_with_actions(user_a, action_user_a, action_user_b):
+    _, title = utils.get_contributions(user_a)
+    assert title == "1 contribution in the last year"