No locales selected.
++ Team community health score + {{ Tooltip.display(intro='Community health score for each team.') }} +
+
@@ -23,8 +88,8 @@
@@ -44,8 +109,8 @@
diff --git a/pontoon/insights/templates/insights/widgets/insights.html b/pontoon/insights/templates/insights/widgets/insights.html
index 001724a2b9..dcaae1010b 100644
--- a/pontoon/insights/templates/insights/widgets/insights.html
+++ b/pontoon/insights/templates/insights/widgets/insights.html
@@ -3,7 +3,7 @@
{# Widget to display insights. #}
{% macro display() %}
-
+
{% if total_users %}
{% endif %}
+ {% if community_health_scores %}
+
+
+
+ Community health score
+ {{
+ Tooltip.display(
+ intro='Community health score at a particular point in time.',
+ items=[{
+ 'class': 'current-month',
+ 'name': 'Current month',
+ 'definition': 'Community health score calculated for a specific month.',
+ }]
+ )
+ }}
+
+
+
+
+
+ {% endif %}
+
@@ -178,11 +206,11 @@
@@ -220,10 +248,10 @@
@@ -265,11 +293,11 @@
diff --git a/pontoon/insights/templates/insights/widgets/locale_list.html b/pontoon/insights/templates/insights/widgets/locale_list.html
new file mode 100644
index 0000000000..98c9a4f3f0
--- /dev/null
+++ b/pontoon/insights/templates/insights/widgets/locale_list.html
@@ -0,0 +1,105 @@
+{% macro header() %}
+
+
+
+ Locale
+ Managers
+ Translators
+
+ Contr. 1
+
+
+ Contr. 2
+
+ New
+
+ Projects
+
+ Completion
+ Score
+
+
+
+
+{% endmacro %}
+
+{% macro delta_span(delta, percent=False) %}
+ {% if delta is not none %}
+
+
+ {% if delta != 0 %}
+ {{ '+' if delta > 0 }}{{ delta }}{{ '%' if percent }}
+ {% endif %}
+
+ {% endif %}
+{% endmacro %}
+
+{% macro value_cell(base_value, base_delta=None, base_threshold=None,
+ score_value=None, score_delta=None, score_threshold=None,
+ percent=False, single=False) %}
+
+ {% if single %}
+
+ {{ delta_span(base_delta, percent) }}
+
+ {{ base_value }}{{ '%' if percent }}
+
+
+ {% else %}
+
+ {{ delta_span(base_delta, percent) }}
+
+ {{ base_value }}{{ '%' if percent }}
+
+
+
+ {{ delta_span(score_delta, false) }}
+
+ {{ score_value }}
+
+
+ {% endif %}
+
+{% endmacro %}
+
+{% macro item(locale, main_link, curr_snapshot=None, base_deltas=None, score_deltas=None, columns=None, class='limited') %}
+
+
+
+
+ {% if curr_snapshot %}
+ {% for field, column in columns.items() %}
+ {% if field == 'chs' %}
+ {{ value_cell(
+ base_value=curr_snapshot[field],
+ base_delta=base_deltas[field] if base_deltas else None,
+ base_threshold=column.base_threshold,
+ single=True) }}
+ {% else %}
+ {{ value_cell(
+ base_value=curr_snapshot[field],
+ base_delta=base_deltas[field] if base_deltas else None,
+ base_threshold=column.base_threshold,
+ score_value=curr_snapshot[field + '_score'],
+ score_delta=score_deltas[field + '_score'] if score_deltas else None,
+ score_threshold=column.score_threshold,
+ percent=column.percent) }}
+ {% endif %}
+ {% endfor %}
+ {% else %}
+
+ No snapshot this month
+
+ {% endif %}
+
+{% endmacro %}
+
+{% macro footer() %}
+
+
+
+
+{% endmacro %}
diff --git a/pontoon/insights/tests/test_tasks.py b/pontoon/insights/tests/test_tasks.py
index 5b57e99985..13753f566b 100644
--- a/pontoon/insights/tests/test_tasks.py
+++ b/pontoon/insights/tests/test_tasks.py
@@ -8,6 +8,15 @@
from django.utils import timezone
from pontoon.actionlog.models import ActionLog
+from pontoon.base.models import Locale
+from pontoon.insights.chs import (
+ KEY_PROJECT_SLUGS,
+ build_chs_snapshots,
+ compute_chs,
+ get_completion_by_locale,
+ get_contributor_metrics_by_locale,
+ get_key_projects_enabled_by_locale,
+)
from pontoon.insights.tasks import (
Activity,
count_activities,
@@ -18,9 +27,13 @@
)
from pontoon.test.factories import (
EntityFactory,
+ GroupFactory,
+ ProjectFactory,
ProjectLocaleFactory,
+ ResourceFactory,
TranslatedResourceFactory,
TranslationFactory,
+ UserFactory,
)
@@ -271,3 +284,278 @@ def test_count_activities_chrf_score_zero_included(
result = count_activities(now)
activity = result[project_locale_a.pk]
assert 0.0 in activity.pretranslations_chrf_scores
+
+
+def test_compute_chs():
+ # full metric chs activity
+ assert compute_chs(
+ {
+ "active_managers": 1,
+ "active_translators": 2,
+ "active_contributors": 2,
+ "all_contributors": 2,
+ "new_signups": 2,
+ "key_projects_enabled": len(KEY_PROJECT_SLUGS),
+ "completion": 100.0,
+ }
+ ) == {
+ "active_managers_score": 20.0,
+ "active_translators_score": 15.0,
+ "active_contributors_score": 6.0,
+ "all_contributors_score": 4.0,
+ "new_signups_score": 5.0,
+ "key_projects_enabled_score": 4.0,
+ "completion_score": 46.0,
+ "chs": 100.0,
+ }
+
+ # half chs metric activity
+ assert compute_chs(
+ {
+ "active_managers": 1,
+ "active_translators": 1,
+ "active_contributors": 1,
+ "all_contributors": 1,
+ "new_signups": 1,
+ "key_projects_enabled": len(KEY_PROJECT_SLUGS),
+ "completion": 50.0,
+ }
+ ) == {
+ "active_managers_score": 20.0,
+ "active_translators_score": 7.5,
+ "active_contributors_score": 3.0,
+ "all_contributors_score": 2.0,
+ "new_signups_score": 2.5,
+ "key_projects_enabled_score": 4,
+ "completion_score": 23.0,
+ "chs": 62,
+ }
+
+ # no chs metric activity
+ assert compute_chs(
+ {
+ "active_managers": 0,
+ "active_translators": 0,
+ "active_contributors": 0,
+ "all_contributors": 0,
+ "new_signups": 0,
+ "key_projects_enabled": 0,
+ "completion": 0.0,
+ }
+ ) == {
+ "active_managers_score": 0,
+ "active_translators_score": 0,
+ "active_contributors_score": 0,
+ "all_contributors_score": 0,
+ "new_signups_score": 0,
+ "key_projects_enabled_score": 0.0,
+ "completion_score": 0.0,
+ "chs": 0.0,
+ }
+
+
+@pytest.mark.django_db
+def test_get_completion_by_locale(locale_a, locale_b):
+ key_project = ProjectFactory.create(slug="firefox", name="Firefox", repositories=[])
+ key_resource_a = ResourceFactory.create(project=key_project, path="key_a.po")
+ key_resource_b = ResourceFactory.create(project=key_project, path="key_b.po")
+ key_resource_c = ResourceFactory.create(project=key_project, path="key_c.po")
+ # A non-key project must not contribute to the completion score.
+ other_resource = ResourceFactory.create(path="other.po")
+
+ TranslatedResourceFactory.create(
+ resource=key_resource_a,
+ locale=locale_a,
+ total_strings=6,
+ approved_strings=5,
+ strings_with_warnings=0,
+ )
+ TranslatedResourceFactory.create(
+ resource=key_resource_b,
+ locale=locale_a,
+ total_strings=4,
+ approved_strings=2,
+ strings_with_warnings=1,
+ )
+ TranslatedResourceFactory.create(
+ resource=key_resource_c,
+ locale=locale_b,
+ total_strings=0,
+ )
+ TranslatedResourceFactory.create(
+ resource=other_resource,
+ locale=locale_a,
+ total_strings=100,
+ approved_strings=100,
+ strings_with_warnings=0,
+ )
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk, locale_b.pk])
+ assert get_completion_by_locale(locales) == {
+ locale_a.pk: 80.0,
+ locale_b.pk: 0.0,
+ }
+
+
+@pytest.mark.django_db
+def test_get_key_projects_enabled_by_locale(locale_a, locale_b):
+ enabled_a = ProjectFactory.create(slug="firefox", name="Firefox", repositories=[])
+ enabled_b = ProjectFactory.create(
+ slug="firefox-for-ios", name="Firefox for iOS", repositories=[]
+ )
+ disabled = ProjectFactory.create(
+ slug="firefox-for-android",
+ name="Firefox for Android",
+ repositories=[],
+ disabled=True,
+ )
+ non_key = ProjectFactory.create(
+ slug="not-a-key-project", name="Other", repositories=[]
+ )
+
+ ProjectLocaleFactory.create(project=enabled_a, locale=locale_a)
+ ProjectLocaleFactory.create(project=enabled_b, locale=locale_a)
+
+ ProjectLocaleFactory.create(project=disabled, locale=locale_a)
+ ProjectLocaleFactory.create(project=non_key, locale=locale_a)
+
+ ProjectLocaleFactory.create(project=non_key, locale=locale_b)
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk, locale_b.pk])
+ assert get_key_projects_enabled_by_locale(locales, KEY_PROJECT_SLUGS) == {
+ locale_a.pk: 2,
+ }
+
+
+@pytest.mark.django_db
+def test_get_contributor_metrics_by_locale(locale_a, locale_b, resource_a):
+ now = timezone.now()
+ in_12_month_window = now - relativedelta(days=1)
+ out_12_month_window = now - relativedelta(months=13, days=1)
+
+ managers_group = GroupFactory.create(name="managers")
+ translators_group = GroupFactory.create(name="translators")
+ locale_a.managers_group = managers_group
+ locale_a.translators_group = translators_group
+ locale_a.save()
+
+ manager = UserFactory.create(username="manager")
+ translator = UserFactory.create(username="translator")
+ contributor_a = UserFactory.create(username="contributor_a")
+ contributor_b = UserFactory.create(username="contributor_b")
+ managers_group.user_set.add(manager)
+ translators_group.user_set.add(translator)
+
+ # Managers and translators need one authored translation to appear in the
+ # results, and cross their thresholds through review actions
+ manager_translation = TranslationFactory.create(
+ entity__resource=resource_a,
+ locale=locale_a,
+ user=manager,
+ date=in_12_month_window,
+ )
+ translator_translation = TranslationFactory.create(
+ entity__resource=resource_a,
+ locale=locale_a,
+ user=translator,
+ date=in_12_month_window,
+ )
+ ActionLog.objects.bulk_create(
+ [
+ ActionLog(
+ action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
+ created_at=in_12_month_window,
+ performed_by=manager,
+ translation=manager_translation,
+ )
+ for _ in range(501)
+ ]
+ + [
+ ActionLog(
+ action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
+ created_at=in_12_month_window,
+ performed_by=translator,
+ translation=translator_translation,
+ )
+ for _ in range(401)
+ ]
+ )
+
+ # An active contributor crosses the active, all-contributor, and new-signup
+ # thresholds at once
+ for entity in EntityFactory.create_batch(size=201, resource=resource_a):
+ TranslationFactory.create(
+ entity=entity,
+ locale=locale_a,
+ user=contributor_a,
+ date=in_12_month_window,
+ approved=True,
+ )
+
+ # A contributor below every threshold is counted nowhere
+ TranslationFactory.create(
+ entity__resource=resource_a,
+ locale=locale_a,
+ user=contributor_b,
+ date=in_12_month_window,
+ approved=True,
+ )
+
+ # contributions made outside 12 month window - current month
+ # are not counted
+ for entity in EntityFactory.create_batch(size=201, resource=resource_a):
+ TranslationFactory.create(
+ entity=entity,
+ locale=locale_b,
+ user=contributor_b,
+ date=out_12_month_window,
+ approved=True,
+ )
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk, locale_b.pk])
+ assert get_contributor_metrics_by_locale(locales, now) == {
+ locale_a.pk: {
+ "active_managers": 1,
+ "active_translators": 1,
+ "active_contributors": 1,
+ "all_contributors": 1,
+ "new_signups": 1,
+ },
+ locale_b.pk: {
+ "active_managers": 0,
+ "active_translators": 0,
+ "active_contributors": 0,
+ "all_contributors": 0,
+ "new_signups": 0,
+ },
+ }
+
+
+@pytest.mark.django_db
+def test_build_chs_snapshots(locale_a):
+ key_project = ProjectFactory.create(slug="firefox", name="Firefox", repositories=[])
+ resource = ResourceFactory.create(project=key_project, path="firefox.po")
+ ProjectLocaleFactory.create(project=key_project, locale=locale_a)
+ TranslatedResourceFactory.create(
+ resource=resource,
+ locale=locale_a,
+ total_strings=10,
+ approved_strings=8,
+ strings_with_warnings=0,
+ )
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk])
+ snapshots = build_chs_snapshots(locales)
+
+ # Only visible locales get snapshots
+ assert {snapshot.locale_id for snapshot in snapshots} == {locale_a.pk}
+ assert len(snapshots) == 1
+
+ snapshot = snapshots[0]
+ assert snapshot.completion == 80.0
+ assert snapshot.key_projects_enabled == 1
+ assert snapshot.active_managers == 0
+ assert snapshot.active_contributors == 0
+ assert snapshot.completion_score == 36.8
+ assert snapshot.key_projects_enabled_score == 0.57
+ assert snapshot.chs == 37.37
diff --git a/pontoon/insights/tests/test_views.py b/pontoon/insights/tests/test_views.py
index db5b919991..687cc16c5b 100644
--- a/pontoon/insights/tests/test_views.py
+++ b/pontoon/insights/tests/test_views.py
@@ -1,5 +1,3 @@
-import json
-
from dataclasses import dataclass
from datetime import datetime, timezone
from http import HTTPStatus
@@ -45,10 +43,10 @@ class MonthlyQualityEntry:
@pytest.mark.django_db
-def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
+def test_default_empty(client_superuser, clear_cache, locale_a, project_a, user_a):
url = reverse("pontoon.insights")
with patch.object(views, "render", wraps=render) as mock_render:
- response = client.get(url)
+ response = client_superuser.get(url)
assert response.status_code == HTTPStatus.OK
response_context = mock_render.call_args[0][2]
@@ -56,7 +54,7 @@ def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
end_date = response_context["end_date"]
assert start_date < end_date <= datetime.now(timezone.utc)
team_pretranslation_quality = response_context["team_pretranslation_quality"]
- assert json.loads(team_pretranslation_quality["dataset"]) == [
+ assert team_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
@@ -76,7 +74,7 @@ def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
}
]
project_pretranslation_quality = response_context["project_pretranslation_quality"]
- assert json.loads(project_pretranslation_quality["dataset"]) == [
+ assert project_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
@@ -98,7 +96,9 @@ def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
@pytest.mark.django_db
-def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, user_a):
+def test_default_with_data(
+ client_superuser, clear_cache, tm_user, locale_a, project_a, user_a
+):
entries = [
MonthlyQualityEntry(months_ago=0, approved=1, rejected=0),
MonthlyQualityEntry(months_ago=1, approved=0, rejected=1),
@@ -135,7 +135,7 @@ def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, us
url = reverse("pontoon.insights")
with patch.object(views, "render", wraps=render) as mock_render:
- response = client.get(url)
+ response = client_superuser.get(url)
assert response.status_code == HTTPStatus.OK
response_context = mock_render.call_args[0][2]
@@ -143,7 +143,7 @@ def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, us
end_date = response_context["end_date"]
assert start_date < end_date <= datetime.now(timezone.utc)
team_pretranslation_quality = response_context["team_pretranslation_quality"]
- assert json.loads(team_pretranslation_quality["dataset"]) == [
+ assert team_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
@@ -180,7 +180,7 @@ def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, us
},
]
project_pretranslation_quality = response_context["project_pretranslation_quality"]
- assert json.loads(project_pretranslation_quality["dataset"]) == [
+ assert project_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
diff --git a/pontoon/insights/urls.py b/pontoon/insights/urls.py
index adf15b015e..bc26fd96aa 100644
--- a/pontoon/insights/urls.py
+++ b/pontoon/insights/urls.py
@@ -1,13 +1,19 @@
from django.urls import path
-from . import views
+from pontoon.insights.views import insights, insights_config
urlpatterns = [
# Insights page
path(
"insights/",
- views.insights,
+ insights,
name="pontoon.insights",
),
+ # Insights config page
+ path(
+ "insights/config",
+ insights_config,
+ name="pontoon.insights.config",
+ ),
]
diff --git a/pontoon/insights/utils.py b/pontoon/insights/utils.py
index 4daceb8027..9306b03cb6 100644
--- a/pontoon/insights/utils.py
+++ b/pontoon/insights/utils.py
@@ -1,5 +1,3 @@
-import json
-
from datetime import timedelta
from dateutil.relativedelta import relativedelta
@@ -12,6 +10,7 @@
from pontoon.actionlog.models import ActionLog
from pontoon.base.utils import convert_to_unix_time
from pontoon.insights.models import (
+ LocaleHealthSnapshot,
LocaleInsightsSnapshot,
ProjectLocaleInsightsSnapshot,
active_users_default,
@@ -90,7 +89,7 @@ def get_time_to_review_12_month_avg(category, query_filters=None):
value = None
times_to_review_12_month_avg.insert(0, value)
- return json.dumps(times_to_review_12_month_avg)
+ return times_to_review_12_month_avg
def get_approval_rate(insight):
@@ -115,6 +114,51 @@ def get_chrf_score(insight):
return round(score, 2)
+def get_locale_health_data(locales):
+ """
+ Get locale health data required by Locale Insights and Global Insights tabs.
+
+ Note: The community health score is computed for a whole month, so the current
+ month's score will not be complete. Therefore, the most recent score available
+ will always be of the previous month. Subsequently, the 12 month window of
+ scores will be displayed starting from the previous month based on the user's
+ date.
+ """
+ end_month = timezone.now().date().replace(day=1) - relativedelta(months=1)
+ months = [end_month - relativedelta(months=11 - i) for i in range(12)]
+ indices = {month: i for i, month in enumerate(months)}
+
+ health_snapshots = (
+ LocaleHealthSnapshot.objects.filter(
+ created_at__gte=months[0], locale__in=locales
+ )
+ .annotate(month=TruncMonth("created_at"))
+ .order_by("created_at")
+ .values("month", "locale__code", "locale__name", "chs")
+ )
+
+ data = {}
+
+ for snapshot in health_snapshots:
+ month = snapshot["month"]
+ if month not in indices:
+ continue
+
+ locale_code = snapshot["locale__code"]
+ item = data.setdefault(
+ locale_code,
+ {
+ "name": f"{snapshot['locale__name']} ยท {locale_code}",
+ "chs": [None] * 12,
+ },
+ )
+ item["chs"][indices[month]] = float(snapshot["chs"])
+
+ dates = [convert_to_unix_time(month) for month in months]
+
+ return dates, data
+
+
def get_locale_insights(query_filters=None):
"""Get data required by the Locale Insights tab.
@@ -223,21 +267,17 @@ def get_locale_insights(query_filters=None):
"unreviewed_lifespans": [
x["unreviewed_lifespan_avg"].days for x in insights
],
- "time_to_review_suggestions": json.dumps(
- [
- get_time_to_review(x["time_to_review_suggestions_avg"])
- for x in insights
- ]
- ),
+ "time_to_review_suggestions": [
+ get_time_to_review(x["time_to_review_suggestions_avg"])
+ for x in insights
+ ],
"time_to_review_suggestions_12_month_avg": get_time_to_review_12_month_avg(
"suggestions", query_filters
),
- "time_to_review_pretranslations": json.dumps(
- [
- get_time_to_review(x["time_to_review_pretranslations_avg"])
- for x in insights
- ]
- ),
+ "time_to_review_pretranslations": [
+ get_time_to_review(x["time_to_review_pretranslations_avg"])
+ for x in insights
+ ],
"time_to_review_pretranslations_12_month_avg": get_time_to_review_12_month_avg(
"pretranslations", query_filters
),
@@ -255,8 +295,8 @@ def get_locale_insights(query_filters=None):
"new_suggestions": [x["new_suggestions_sum"] for x in insights],
},
"pretranslation_quality": {
- "approval_rate": json.dumps([get_approval_rate(x) for x in insights]),
- "chrf_score": json.dumps([get_chrf_score(x) for x in insights]),
+ "approval_rate": [get_approval_rate(x) for x in insights],
+ "chrf_score": [get_chrf_score(x) for x in insights],
"approved": [x["pretranslations_approved_sum"] for x in insights],
"rejected": [x["pretranslations_rejected_sum"] for x in insights],
"new": [x["pretranslations_new_sum"] for x in insights],
@@ -267,6 +307,20 @@ def get_locale_insights(query_filters=None):
return output
+def get_locale_health_insights(locale):
+ """Get locale health data required by Locale Insights tab."""
+ dates, data = get_locale_health_data([locale])
+
+ locale_health = data.get(locale.code)
+
+ return {
+ "community_health_dates": dates,
+ "community_health_scores": locale_health["chs"]
+ if locale_health
+ else [None] * 12,
+ }
+
+
def get_insights(locale=None, project=None):
"""Get data required by the Insights tab."""
start_date = get_insight_start_date()
@@ -361,8 +415,8 @@ def get_insights(locale=None, project=None):
"new_suggestions": [x["new_suggestions_sum"] for x in insights],
},
"pretranslation_quality": {
- "approval_rate": json.dumps([get_approval_rate(x) for x in insights]),
- "chrf_score": json.dumps([get_chrf_score(x) for x in insights]),
+ "approval_rate": [get_approval_rate(x) for x in insights],
+ "chrf_score": [get_chrf_score(x) for x in insights],
"approved": [x["pretranslations_approved_sum"] for x in insights],
"rejected": [x["pretranslations_rejected_sum"] for x in insights],
"new": [x["pretranslations_new_sum"] for x in insights],
@@ -462,5 +516,15 @@ def get_global_pretranslation_quality(category, id):
return {
"dates": sorted(list({convert_to_unix_time(x["month"]) for x in actions})),
- "dataset": json.dumps([v for _, v in data.items()]),
+ "dataset": [v for _, v in data.items()],
+ }
+
+
+def get_global_locale_health_insights(locales):
+ """Get locale health data required by Global Insights tab."""
+ dates, data = get_locale_health_data(locales)
+
+ return {
+ "dates": dates,
+ "dataset": list(data.values()),
}
diff --git a/pontoon/insights/views.py b/pontoon/insights/views.py
index 68f5e9568e..b79fbb6a8c 100644
--- a/pontoon/insights/views.py
+++ b/pontoon/insights/views.py
@@ -3,22 +3,194 @@
from dateutil.relativedelta import relativedelta
from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
from django.core.cache import cache
-from django.core.exceptions import ImproperlyConfigured
-from django.shortcuts import render
+from django.core.exceptions import ImproperlyConfigured, PermissionDenied
+from django.shortcuts import redirect, render
from django.utils import timezone
-from pontoon.insights.utils import get_global_pretranslation_quality
+from pontoon.base.forms import UserInsightsDashboardConfigForm
+from pontoon.base.models.locale import Locale
+from pontoon.insights.chs import KEY_PROJECT_SLUGS
+from pontoon.insights.models import LocaleHealthSnapshot
+from pontoon.insights.utils import (
+ get_global_locale_health_insights,
+ get_global_pretranslation_quality,
+)
+from pontoon.settings.base import (
+ ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ ACTIVE_CONTRIBUTOR_POINTS,
+ ALL_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ ALL_CONTRIBUTOR_POINTS,
+ COMPLETION_POINTS,
+ ENABLED_PROJECT_POINTS,
+ MANAGER_PEOPLE_THRESHOLD,
+ MANAGER_POINTS,
+ NEW_SIGNUP_PEOPLE_THRESHOLD,
+ NEW_SIGNUP_POINTS,
+ TRANSLATOR_PEOPLE_THRESHOLD,
+ TRANSLATOR_POINTS,
+)
log = logging.getLogger(__name__)
+CHS_BASE_METRICS = [
+ "active_managers",
+ "active_translators",
+ "active_contributors",
+ "all_contributors",
+ "new_signups",
+ "key_projects_enabled",
+ "completion",
+ "chs",
+]
+
+CHS_SCORE_METRICS = [
+ "key_projects_enabled_score",
+ "active_managers_score",
+ "active_translators_score",
+ "active_contributors_score",
+ "all_contributors_score",
+ "new_signups_score",
+ "completion_score",
+ "chs",
+]
+
+CHS_COLUMNS = {
+ "active_managers": {
+ "base_threshold": MANAGER_PEOPLE_THRESHOLD,
+ "score_threshold": MANAGER_POINTS,
+ },
+ "active_translators": {
+ "base_threshold": TRANSLATOR_PEOPLE_THRESHOLD,
+ "score_threshold": TRANSLATOR_POINTS,
+ },
+ "active_contributors": {
+ "base_threshold": ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ "score_threshold": ACTIVE_CONTRIBUTOR_POINTS,
+ },
+ "all_contributors": {
+ "base_threshold": ALL_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ "score_threshold": ALL_CONTRIBUTOR_POINTS,
+ },
+ "new_signups": {
+ "base_threshold": NEW_SIGNUP_PEOPLE_THRESHOLD,
+ "score_threshold": NEW_SIGNUP_POINTS,
+ },
+ "key_projects_enabled": {
+ "base_threshold": len(KEY_PROJECT_SLUGS),
+ "score_threshold": ENABLED_PROJECT_POINTS,
+ },
+ "completion": {
+ "base_threshold": 100,
+ "percent": True,
+ "score_threshold": COMPLETION_POINTS,
+ },
+ "chs": {"base_threshold": 100},
+}
+
+
+def get_monthly_snapshots(locales, date):
+ month_start = date.replace(day=1)
+ next_month_start = month_start + relativedelta(months=1)
+
+ snapshots = LocaleHealthSnapshot.objects.filter(
+ locale__in=locales,
+ created_at__gte=month_start,
+ created_at__lt=next_month_start,
+ ).order_by("locale_id", "-created_at")
+
+ latest = {}
+ for snapshot in snapshots:
+ latest.setdefault(snapshot.locale_id, snapshot)
+
+ return latest
+
+
+def get_monthly_snapshot_deltas(current_snapshots, previous_snapshots, metrics):
+ deltas = {}
+ for locale_id, curr_snapshot in current_snapshots.items():
+ prev_snapshot = previous_snapshots.get(locale_id)
+ locale_deltas = {}
+ for column_key in metrics:
+ if prev_snapshot is None:
+ locale_deltas[column_key] = None
+ continue
+ curr_value = getattr(curr_snapshot, column_key)
+ prev_value = getattr(prev_snapshot, column_key)
+ locale_deltas[column_key] = curr_value - prev_value
+ deltas[locale_id] = locale_deltas
+
+ return deltas
+
+
+@login_required(redirect_field_name="", login_url="/403")
+def insights_config(request):
+ """Configure which locales appear on the Insights dashboard."""
+ if not settings.ENABLE_INSIGHTS:
+ raise ImproperlyConfigured("ENABLE_INSIGHTS variable not set in settings.")
+
+ user = request.user
+ profile = user.profile
+
+ if not user.is_staff:
+ raise PermissionDenied
+
+ if request.method == "POST":
+ dashboard_locales_form = UserInsightsDashboardConfigForm(
+ request.POST, instance=profile
+ )
+
+ if dashboard_locales_form.is_valid():
+ dashboard_locales_form.save()
+ messages.success(request, "Configuration saved.")
+ return redirect("pontoon.insights")
+
+ dashboard_locales = profile.dashboard_locales
+
+ locales = Locale.objects.visible()
+ selected_locales = locales.filter(pk__in=dashboard_locales)
+ available_locales = locales.exclude(pk__in=dashboard_locales)
+ return render(
+ request,
+ "insights/config.html",
+ {
+ "available_locales": available_locales,
+ "selected_locales": selected_locales,
+ },
+ )
+
+
+@login_required(redirect_field_name="", login_url="/403")
def insights(request):
"""Insights page."""
+
if not settings.ENABLE_INSIGHTS:
raise ImproperlyConfigured("ENABLE_INSIGHTS variable not set in settings.")
+ user = request.user
+ profile = user.profile
+
+ if not user.is_staff:
+ raise PermissionDenied
+
+ dashboard_locales = profile.dashboard_locales
+ locales = Locale.objects.filter(pk__in=dashboard_locales).order_by("code")
+
+ current_anchor = timezone.now().date()
+ previous_anchor = current_anchor.replace(day=1) - relativedelta(days=1)
+ current_snapshots = get_monthly_snapshots(locales, current_anchor)
+ previous_snapshots = get_monthly_snapshots(locales, previous_anchor)
+ snapshot_base_deltas = get_monthly_snapshot_deltas(
+ current_snapshots, previous_snapshots, CHS_BASE_METRICS
+ )
+ snapshot_score_deltas = get_monthly_snapshot_deltas(
+ current_snapshots, previous_snapshots, CHS_SCORE_METRICS
+ )
+
# Cannot use cache.get_or_set(), because it always calls the slow function
# get_global_pretranslation_quality(). The reason we use cache in first place is to
# avoid that.
@@ -41,13 +213,21 @@ def insights(request):
project_pt_key, project_pretranslation_quality, settings.VIEW_CACHE_TIMEOUT
)
+ global_locale_health_insights = get_global_locale_health_insights(locales)
+
return render(
request,
"insights/insights.html",
{
"start_date": timezone.now() - relativedelta(years=1),
"end_date": timezone.now(),
+ "locales": locales,
+ "current_snapshots": current_snapshots,
+ "snapshot_base_deltas": snapshot_base_deltas,
+ "snapshot_score_deltas": snapshot_score_deltas,
+ "columns": CHS_COLUMNS,
"team_pretranslation_quality": team_pretranslation_quality,
"project_pretranslation_quality": project_pretranslation_quality,
+ "global_locale_health_insights": global_locale_health_insights,
},
)
diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py
index b3114234e7..97c169b84c 100644
--- a/pontoon/settings/base.py
+++ b/pontoon/settings/base.py
@@ -460,8 +460,13 @@ def _default_from_email():
},
"insights": {
"source_filenames": (
+ "css/heading_info.css",
+ "css/table.css",
+ "css/request.css",
+ "css/multiple_item_selector.css",
"css/insights_charts.css",
"css/insights.css",
+ "css/config.css",
),
"output_filename": "css/insights.min.css",
},
@@ -619,6 +624,8 @@ def _default_from_email():
"source_filenames": (
"js/lib/chart.umd.min.js",
"js/lib/chartjs-adapter-date-fns.bundle.min.js",
+ "js/table.js",
+ "js/multiple_item_selector.js",
"js/insights_charts.js",
"js/insights.js",
),
@@ -1212,6 +1219,11 @@ def account_username(user):
# email will be sent.
MONTHLY_ACTIVITY_SUMMARY_DAY = os.environ.get("MONTHLY_ACTIVITY_SUMMARY_DAY", 1)
+# Integer representing a day of the month on which the Community Health Score
+# snapshots are collected via collect_chs_snapshots().
+MONTHLY_CHS_SNAPSHOTS_DAY = os.environ.get("MONTHLY_CHS_SNAPSHOTS_DAY", 1)
+
+
# Number of days after user registration to send the 2nd onboarding email
ONBOARDING_EMAIL_2_DELAY = os.environ.get("ONBOARDING_EMAIL_2_DELAY", 2)
@@ -1255,6 +1267,35 @@ def account_username(user):
map(int, os.environ.get("BADGES_PROMOTION_THRESHOLDS", "1, 2, 5").split(","))
)
+# Used for Community Health Score calculations
+MANAGER_STRING_THRESHOLD = int(os.environ.get("MANAGER_STRING_THRESHOLD", 500))
+TRANSLATOR_STRING_THRESHOLD = int(os.environ.get("TRANSLATOR_STRING_THRESHOLD", 400))
+ACTIVE_CONTRIBUTOR_STRING_THRESHOLD = int(
+ os.environ.get("ACTIVE_CONTRIBUTOR_STRING_THRESHOLD", 200)
+)
+ALL_CONTRIBUTOR_STRING_THRESHOLD = int(
+ os.environ.get("ALL_CONTRIBUTOR_STRING_THRESHOLD", 200)
+)
+NEW_SIGNUP_STRING_THRESHOLD = int(os.environ.get("NEW_SIGNUP_STRING_THRESHOLD", 100))
+
+MANAGER_PEOPLE_THRESHOLD = int(os.environ.get("MANAGER_PEOPLE_THRESHOLD", 1))
+TRANSLATOR_PEOPLE_THRESHOLD = int(os.environ.get("TRANSLATOR_PEOPLE_THRESHOLD", 2))
+ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD = int(
+ os.environ.get("ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD", 2)
+)
+ALL_CONTRIBUTOR_PEOPLE_THRESHOLD = int(
+ os.environ.get("ALL_CONTRIBUTOR_PEOPLE_THRESHOLD", 2)
+)
+NEW_SIGNUP_PEOPLE_THRESHOLD = int(os.environ.get("NEW_SIGNUP_PEOPLE_THRESHOLD", 2))
+
+MANAGER_POINTS = float(os.environ.get("MANAGER_POINTS", 20.0))
+TRANSLATOR_POINTS = float(os.environ.get("TRANSLATOR_POINTS", 15.0))
+ACTIVE_CONTRIBUTOR_POINTS = float(os.environ.get("ACTIVE_CONTRIBUTOR_POINTS", 6.0))
+ALL_CONTRIBUTOR_POINTS = float(os.environ.get("ALL_CONTRIBUTOR_POINTS", 4.0))
+NEW_SIGNUP_POINTS = float(os.environ.get("NEW_SIGNUP_POINTS", 5.0))
+ENABLED_PROJECT_POINTS = float(os.environ.get("ENABLED_PROJECT_POINTS", 4.0))
+COMPLETION_POINTS = float(os.environ.get("COMPLETION_POINTS", 46.0))
+
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Used in the header of the Terminology (.TBX) files.
diff --git a/pontoon/teams/views.py b/pontoon/teams/views.py
index 0dd72831ac..141decc419 100644
--- a/pontoon/teams/views.py
+++ b/pontoon/teams/views.py
@@ -54,7 +54,7 @@
)
from pontoon.base.utils import require_AJAX
from pontoon.contributors.views import ContributorsMixin
-from pontoon.insights.utils import get_locale_insights
+from pontoon.insights.utils import get_locale_health_insights, get_locale_insights
from pontoon.teams.forms import LocaleRequestForm
from ..base.models.project import ProjectQuerySet
@@ -209,7 +209,9 @@ def ajax_insights(request, locale):
key = f"/{__name__}/{locale.code}/insights"
insights = cache.get(key)
if not insights:
- insights = get_locale_insights(Q(locale=locale))
+ locale_insights = get_locale_insights(Q(locale=locale))
+ locale_health_insights = get_locale_health_insights(locale)
+ insights = locale_insights | locale_health_insights
cache.set(key, insights, settings.VIEW_CACHE_TIMEOUT)
return render(request, "teams/includes/insights.html", insights)
diff --git a/pontoon/test/fixtures/base.py b/pontoon/test/fixtures/base.py
index 383f91657b..8146e30f52 100644
--- a/pontoon/test/fixtures/base.py
+++ b/pontoon/test/fixtures/base.py
@@ -11,6 +11,7 @@ def admin():
return factories.UserFactory.create(
username="admin",
is_superuser=True,
+ is_staff=True,
)
diff --git a/pontoon/insights/templates/insights/widgets/insights.html b/pontoon/insights/templates/insights/widgets/insights.html
index 001724a2b9..dcaae1010b 100644
--- a/pontoon/insights/templates/insights/widgets/insights.html
+++ b/pontoon/insights/templates/insights/widgets/insights.html
@@ -3,7 +3,7 @@
{# Widget to display insights. #}
{% macro display() %}
-
+
{% if total_users %}
{% endif %}
+ {% if community_health_scores %}
+
+
+
+ Community health score
+ {{
+ Tooltip.display(
+ intro='Community health score at a particular point in time.',
+ items=[{
+ 'class': 'current-month',
+ 'name': 'Current month',
+ 'definition': 'Community health score calculated for a specific month.',
+ }]
+ )
+ }}
+
+
+
+
+
+ {% endif %}
+
@@ -178,11 +206,11 @@
@@ -220,10 +248,10 @@
@@ -265,11 +293,11 @@
diff --git a/pontoon/insights/templates/insights/widgets/locale_list.html b/pontoon/insights/templates/insights/widgets/locale_list.html
new file mode 100644
index 0000000000..98c9a4f3f0
--- /dev/null
+++ b/pontoon/insights/templates/insights/widgets/locale_list.html
@@ -0,0 +1,105 @@
+{% macro header() %}
+
+
+
+ Locale
+ Managers
+ Translators
+
+ Contr. 1
+
+
+ Contr. 2
+
+ New
+
+ Projects
+
+ Completion
+ Score
+
+
+
+
+{% endmacro %}
+
+{% macro delta_span(delta, percent=False) %}
+ {% if delta is not none %}
+
+
+ {% if delta != 0 %}
+ {{ '+' if delta > 0 }}{{ delta }}{{ '%' if percent }}
+ {% endif %}
+
+ {% endif %}
+{% endmacro %}
+
+{% macro value_cell(base_value, base_delta=None, base_threshold=None,
+ score_value=None, score_delta=None, score_threshold=None,
+ percent=False, single=False) %}
+
+ {% if single %}
+
+ {{ delta_span(base_delta, percent) }}
+
+ {{ base_value }}{{ '%' if percent }}
+
+
+ {% else %}
+
+ {{ delta_span(base_delta, percent) }}
+
+ {{ base_value }}{{ '%' if percent }}
+
+
+
+ {{ delta_span(score_delta, false) }}
+
+ {{ score_value }}
+
+
+ {% endif %}
+
+{% endmacro %}
+
+{% macro item(locale, main_link, curr_snapshot=None, base_deltas=None, score_deltas=None, columns=None, class='limited') %}
+
+
+
+
+ {% if curr_snapshot %}
+ {% for field, column in columns.items() %}
+ {% if field == 'chs' %}
+ {{ value_cell(
+ base_value=curr_snapshot[field],
+ base_delta=base_deltas[field] if base_deltas else None,
+ base_threshold=column.base_threshold,
+ single=True) }}
+ {% else %}
+ {{ value_cell(
+ base_value=curr_snapshot[field],
+ base_delta=base_deltas[field] if base_deltas else None,
+ base_threshold=column.base_threshold,
+ score_value=curr_snapshot[field + '_score'],
+ score_delta=score_deltas[field + '_score'] if score_deltas else None,
+ score_threshold=column.score_threshold,
+ percent=column.percent) }}
+ {% endif %}
+ {% endfor %}
+ {% else %}
+
+ No snapshot this month
+
+ {% endif %}
+
+{% endmacro %}
+
+{% macro footer() %}
+
+
+
+
+{% endmacro %}
diff --git a/pontoon/insights/tests/test_tasks.py b/pontoon/insights/tests/test_tasks.py
index 5b57e99985..13753f566b 100644
--- a/pontoon/insights/tests/test_tasks.py
+++ b/pontoon/insights/tests/test_tasks.py
@@ -8,6 +8,15 @@
from django.utils import timezone
from pontoon.actionlog.models import ActionLog
+from pontoon.base.models import Locale
+from pontoon.insights.chs import (
+ KEY_PROJECT_SLUGS,
+ build_chs_snapshots,
+ compute_chs,
+ get_completion_by_locale,
+ get_contributor_metrics_by_locale,
+ get_key_projects_enabled_by_locale,
+)
from pontoon.insights.tasks import (
Activity,
count_activities,
@@ -18,9 +27,13 @@
)
from pontoon.test.factories import (
EntityFactory,
+ GroupFactory,
+ ProjectFactory,
ProjectLocaleFactory,
+ ResourceFactory,
TranslatedResourceFactory,
TranslationFactory,
+ UserFactory,
)
@@ -271,3 +284,278 @@ def test_count_activities_chrf_score_zero_included(
result = count_activities(now)
activity = result[project_locale_a.pk]
assert 0.0 in activity.pretranslations_chrf_scores
+
+
+def test_compute_chs():
+ # full metric chs activity
+ assert compute_chs(
+ {
+ "active_managers": 1,
+ "active_translators": 2,
+ "active_contributors": 2,
+ "all_contributors": 2,
+ "new_signups": 2,
+ "key_projects_enabled": len(KEY_PROJECT_SLUGS),
+ "completion": 100.0,
+ }
+ ) == {
+ "active_managers_score": 20.0,
+ "active_translators_score": 15.0,
+ "active_contributors_score": 6.0,
+ "all_contributors_score": 4.0,
+ "new_signups_score": 5.0,
+ "key_projects_enabled_score": 4.0,
+ "completion_score": 46.0,
+ "chs": 100.0,
+ }
+
+ # half chs metric activity
+ assert compute_chs(
+ {
+ "active_managers": 1,
+ "active_translators": 1,
+ "active_contributors": 1,
+ "all_contributors": 1,
+ "new_signups": 1,
+ "key_projects_enabled": len(KEY_PROJECT_SLUGS),
+ "completion": 50.0,
+ }
+ ) == {
+ "active_managers_score": 20.0,
+ "active_translators_score": 7.5,
+ "active_contributors_score": 3.0,
+ "all_contributors_score": 2.0,
+ "new_signups_score": 2.5,
+ "key_projects_enabled_score": 4,
+ "completion_score": 23.0,
+ "chs": 62,
+ }
+
+ # no chs metric activity
+ assert compute_chs(
+ {
+ "active_managers": 0,
+ "active_translators": 0,
+ "active_contributors": 0,
+ "all_contributors": 0,
+ "new_signups": 0,
+ "key_projects_enabled": 0,
+ "completion": 0.0,
+ }
+ ) == {
+ "active_managers_score": 0,
+ "active_translators_score": 0,
+ "active_contributors_score": 0,
+ "all_contributors_score": 0,
+ "new_signups_score": 0,
+ "key_projects_enabled_score": 0.0,
+ "completion_score": 0.0,
+ "chs": 0.0,
+ }
+
+
+@pytest.mark.django_db
+def test_get_completion_by_locale(locale_a, locale_b):
+ key_project = ProjectFactory.create(slug="firefox", name="Firefox", repositories=[])
+ key_resource_a = ResourceFactory.create(project=key_project, path="key_a.po")
+ key_resource_b = ResourceFactory.create(project=key_project, path="key_b.po")
+ key_resource_c = ResourceFactory.create(project=key_project, path="key_c.po")
+ # A non-key project must not contribute to the completion score.
+ other_resource = ResourceFactory.create(path="other.po")
+
+ TranslatedResourceFactory.create(
+ resource=key_resource_a,
+ locale=locale_a,
+ total_strings=6,
+ approved_strings=5,
+ strings_with_warnings=0,
+ )
+ TranslatedResourceFactory.create(
+ resource=key_resource_b,
+ locale=locale_a,
+ total_strings=4,
+ approved_strings=2,
+ strings_with_warnings=1,
+ )
+ TranslatedResourceFactory.create(
+ resource=key_resource_c,
+ locale=locale_b,
+ total_strings=0,
+ )
+ TranslatedResourceFactory.create(
+ resource=other_resource,
+ locale=locale_a,
+ total_strings=100,
+ approved_strings=100,
+ strings_with_warnings=0,
+ )
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk, locale_b.pk])
+ assert get_completion_by_locale(locales) == {
+ locale_a.pk: 80.0,
+ locale_b.pk: 0.0,
+ }
+
+
+@pytest.mark.django_db
+def test_get_key_projects_enabled_by_locale(locale_a, locale_b):
+ enabled_a = ProjectFactory.create(slug="firefox", name="Firefox", repositories=[])
+ enabled_b = ProjectFactory.create(
+ slug="firefox-for-ios", name="Firefox for iOS", repositories=[]
+ )
+ disabled = ProjectFactory.create(
+ slug="firefox-for-android",
+ name="Firefox for Android",
+ repositories=[],
+ disabled=True,
+ )
+ non_key = ProjectFactory.create(
+ slug="not-a-key-project", name="Other", repositories=[]
+ )
+
+ ProjectLocaleFactory.create(project=enabled_a, locale=locale_a)
+ ProjectLocaleFactory.create(project=enabled_b, locale=locale_a)
+
+ ProjectLocaleFactory.create(project=disabled, locale=locale_a)
+ ProjectLocaleFactory.create(project=non_key, locale=locale_a)
+
+ ProjectLocaleFactory.create(project=non_key, locale=locale_b)
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk, locale_b.pk])
+ assert get_key_projects_enabled_by_locale(locales, KEY_PROJECT_SLUGS) == {
+ locale_a.pk: 2,
+ }
+
+
+@pytest.mark.django_db
+def test_get_contributor_metrics_by_locale(locale_a, locale_b, resource_a):
+ now = timezone.now()
+ in_12_month_window = now - relativedelta(days=1)
+ out_12_month_window = now - relativedelta(months=13, days=1)
+
+ managers_group = GroupFactory.create(name="managers")
+ translators_group = GroupFactory.create(name="translators")
+ locale_a.managers_group = managers_group
+ locale_a.translators_group = translators_group
+ locale_a.save()
+
+ manager = UserFactory.create(username="manager")
+ translator = UserFactory.create(username="translator")
+ contributor_a = UserFactory.create(username="contributor_a")
+ contributor_b = UserFactory.create(username="contributor_b")
+ managers_group.user_set.add(manager)
+ translators_group.user_set.add(translator)
+
+ # Managers and translators need one authored translation to appear in the
+ # results, and cross their thresholds through review actions
+ manager_translation = TranslationFactory.create(
+ entity__resource=resource_a,
+ locale=locale_a,
+ user=manager,
+ date=in_12_month_window,
+ )
+ translator_translation = TranslationFactory.create(
+ entity__resource=resource_a,
+ locale=locale_a,
+ user=translator,
+ date=in_12_month_window,
+ )
+ ActionLog.objects.bulk_create(
+ [
+ ActionLog(
+ action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
+ created_at=in_12_month_window,
+ performed_by=manager,
+ translation=manager_translation,
+ )
+ for _ in range(501)
+ ]
+ + [
+ ActionLog(
+ action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
+ created_at=in_12_month_window,
+ performed_by=translator,
+ translation=translator_translation,
+ )
+ for _ in range(401)
+ ]
+ )
+
+ # An active contributor crosses the active, all-contributor, and new-signup
+ # thresholds at once
+ for entity in EntityFactory.create_batch(size=201, resource=resource_a):
+ TranslationFactory.create(
+ entity=entity,
+ locale=locale_a,
+ user=contributor_a,
+ date=in_12_month_window,
+ approved=True,
+ )
+
+ # A contributor below every threshold is counted nowhere
+ TranslationFactory.create(
+ entity__resource=resource_a,
+ locale=locale_a,
+ user=contributor_b,
+ date=in_12_month_window,
+ approved=True,
+ )
+
+ # contributions made outside 12 month window - current month
+ # are not counted
+ for entity in EntityFactory.create_batch(size=201, resource=resource_a):
+ TranslationFactory.create(
+ entity=entity,
+ locale=locale_b,
+ user=contributor_b,
+ date=out_12_month_window,
+ approved=True,
+ )
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk, locale_b.pk])
+ assert get_contributor_metrics_by_locale(locales, now) == {
+ locale_a.pk: {
+ "active_managers": 1,
+ "active_translators": 1,
+ "active_contributors": 1,
+ "all_contributors": 1,
+ "new_signups": 1,
+ },
+ locale_b.pk: {
+ "active_managers": 0,
+ "active_translators": 0,
+ "active_contributors": 0,
+ "all_contributors": 0,
+ "new_signups": 0,
+ },
+ }
+
+
+@pytest.mark.django_db
+def test_build_chs_snapshots(locale_a):
+ key_project = ProjectFactory.create(slug="firefox", name="Firefox", repositories=[])
+ resource = ResourceFactory.create(project=key_project, path="firefox.po")
+ ProjectLocaleFactory.create(project=key_project, locale=locale_a)
+ TranslatedResourceFactory.create(
+ resource=resource,
+ locale=locale_a,
+ total_strings=10,
+ approved_strings=8,
+ strings_with_warnings=0,
+ )
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk])
+ snapshots = build_chs_snapshots(locales)
+
+ # Only visible locales get snapshots
+ assert {snapshot.locale_id for snapshot in snapshots} == {locale_a.pk}
+ assert len(snapshots) == 1
+
+ snapshot = snapshots[0]
+ assert snapshot.completion == 80.0
+ assert snapshot.key_projects_enabled == 1
+ assert snapshot.active_managers == 0
+ assert snapshot.active_contributors == 0
+ assert snapshot.completion_score == 36.8
+ assert snapshot.key_projects_enabled_score == 0.57
+ assert snapshot.chs == 37.37
diff --git a/pontoon/insights/tests/test_views.py b/pontoon/insights/tests/test_views.py
index db5b919991..687cc16c5b 100644
--- a/pontoon/insights/tests/test_views.py
+++ b/pontoon/insights/tests/test_views.py
@@ -1,5 +1,3 @@
-import json
-
from dataclasses import dataclass
from datetime import datetime, timezone
from http import HTTPStatus
@@ -45,10 +43,10 @@ class MonthlyQualityEntry:
@pytest.mark.django_db
-def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
+def test_default_empty(client_superuser, clear_cache, locale_a, project_a, user_a):
url = reverse("pontoon.insights")
with patch.object(views, "render", wraps=render) as mock_render:
- response = client.get(url)
+ response = client_superuser.get(url)
assert response.status_code == HTTPStatus.OK
response_context = mock_render.call_args[0][2]
@@ -56,7 +54,7 @@ def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
end_date = response_context["end_date"]
assert start_date < end_date <= datetime.now(timezone.utc)
team_pretranslation_quality = response_context["team_pretranslation_quality"]
- assert json.loads(team_pretranslation_quality["dataset"]) == [
+ assert team_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
@@ -76,7 +74,7 @@ def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
}
]
project_pretranslation_quality = response_context["project_pretranslation_quality"]
- assert json.loads(project_pretranslation_quality["dataset"]) == [
+ assert project_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
@@ -98,7 +96,9 @@ def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
@pytest.mark.django_db
-def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, user_a):
+def test_default_with_data(
+ client_superuser, clear_cache, tm_user, locale_a, project_a, user_a
+):
entries = [
MonthlyQualityEntry(months_ago=0, approved=1, rejected=0),
MonthlyQualityEntry(months_ago=1, approved=0, rejected=1),
@@ -135,7 +135,7 @@ def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, us
url = reverse("pontoon.insights")
with patch.object(views, "render", wraps=render) as mock_render:
- response = client.get(url)
+ response = client_superuser.get(url)
assert response.status_code == HTTPStatus.OK
response_context = mock_render.call_args[0][2]
@@ -143,7 +143,7 @@ def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, us
end_date = response_context["end_date"]
assert start_date < end_date <= datetime.now(timezone.utc)
team_pretranslation_quality = response_context["team_pretranslation_quality"]
- assert json.loads(team_pretranslation_quality["dataset"]) == [
+ assert team_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
@@ -180,7 +180,7 @@ def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, us
},
]
project_pretranslation_quality = response_context["project_pretranslation_quality"]
- assert json.loads(project_pretranslation_quality["dataset"]) == [
+ assert project_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
diff --git a/pontoon/insights/urls.py b/pontoon/insights/urls.py
index adf15b015e..bc26fd96aa 100644
--- a/pontoon/insights/urls.py
+++ b/pontoon/insights/urls.py
@@ -1,13 +1,19 @@
from django.urls import path
-from . import views
+from pontoon.insights.views import insights, insights_config
urlpatterns = [
# Insights page
path(
"insights/",
- views.insights,
+ insights,
name="pontoon.insights",
),
+ # Insights config page
+ path(
+ "insights/config",
+ insights_config,
+ name="pontoon.insights.config",
+ ),
]
diff --git a/pontoon/insights/utils.py b/pontoon/insights/utils.py
index 4daceb8027..9306b03cb6 100644
--- a/pontoon/insights/utils.py
+++ b/pontoon/insights/utils.py
@@ -1,5 +1,3 @@
-import json
-
from datetime import timedelta
from dateutil.relativedelta import relativedelta
@@ -12,6 +10,7 @@
from pontoon.actionlog.models import ActionLog
from pontoon.base.utils import convert_to_unix_time
from pontoon.insights.models import (
+ LocaleHealthSnapshot,
LocaleInsightsSnapshot,
ProjectLocaleInsightsSnapshot,
active_users_default,
@@ -90,7 +89,7 @@ def get_time_to_review_12_month_avg(category, query_filters=None):
value = None
times_to_review_12_month_avg.insert(0, value)
- return json.dumps(times_to_review_12_month_avg)
+ return times_to_review_12_month_avg
def get_approval_rate(insight):
@@ -115,6 +114,51 @@ def get_chrf_score(insight):
return round(score, 2)
+def get_locale_health_data(locales):
+ """
+ Get locale health data required by Locale Insights and Global Insights tabs.
+
+ Note: The community health score is computed for a whole month, so the current
+ month's score will not be complete. Therefore, the most recent score available
+ will always be of the previous month. Subsequently, the 12 month window of
+ scores will be displayed starting from the previous month based on the user's
+ date.
+ """
+ end_month = timezone.now().date().replace(day=1) - relativedelta(months=1)
+ months = [end_month - relativedelta(months=11 - i) for i in range(12)]
+ indices = {month: i for i, month in enumerate(months)}
+
+ health_snapshots = (
+ LocaleHealthSnapshot.objects.filter(
+ created_at__gte=months[0], locale__in=locales
+ )
+ .annotate(month=TruncMonth("created_at"))
+ .order_by("created_at")
+ .values("month", "locale__code", "locale__name", "chs")
+ )
+
+ data = {}
+
+ for snapshot in health_snapshots:
+ month = snapshot["month"]
+ if month not in indices:
+ continue
+
+ locale_code = snapshot["locale__code"]
+ item = data.setdefault(
+ locale_code,
+ {
+ "name": f"{snapshot['locale__name']} ยท {locale_code}",
+ "chs": [None] * 12,
+ },
+ )
+ item["chs"][indices[month]] = float(snapshot["chs"])
+
+ dates = [convert_to_unix_time(month) for month in months]
+
+ return dates, data
+
+
def get_locale_insights(query_filters=None):
"""Get data required by the Locale Insights tab.
@@ -223,21 +267,17 @@ def get_locale_insights(query_filters=None):
"unreviewed_lifespans": [
x["unreviewed_lifespan_avg"].days for x in insights
],
- "time_to_review_suggestions": json.dumps(
- [
- get_time_to_review(x["time_to_review_suggestions_avg"])
- for x in insights
- ]
- ),
+ "time_to_review_suggestions": [
+ get_time_to_review(x["time_to_review_suggestions_avg"])
+ for x in insights
+ ],
"time_to_review_suggestions_12_month_avg": get_time_to_review_12_month_avg(
"suggestions", query_filters
),
- "time_to_review_pretranslations": json.dumps(
- [
- get_time_to_review(x["time_to_review_pretranslations_avg"])
- for x in insights
- ]
- ),
+ "time_to_review_pretranslations": [
+ get_time_to_review(x["time_to_review_pretranslations_avg"])
+ for x in insights
+ ],
"time_to_review_pretranslations_12_month_avg": get_time_to_review_12_month_avg(
"pretranslations", query_filters
),
@@ -255,8 +295,8 @@ def get_locale_insights(query_filters=None):
"new_suggestions": [x["new_suggestions_sum"] for x in insights],
},
"pretranslation_quality": {
- "approval_rate": json.dumps([get_approval_rate(x) for x in insights]),
- "chrf_score": json.dumps([get_chrf_score(x) for x in insights]),
+ "approval_rate": [get_approval_rate(x) for x in insights],
+ "chrf_score": [get_chrf_score(x) for x in insights],
"approved": [x["pretranslations_approved_sum"] for x in insights],
"rejected": [x["pretranslations_rejected_sum"] for x in insights],
"new": [x["pretranslations_new_sum"] for x in insights],
@@ -267,6 +307,20 @@ def get_locale_insights(query_filters=None):
return output
+def get_locale_health_insights(locale):
+ """Get locale health data required by Locale Insights tab."""
+ dates, data = get_locale_health_data([locale])
+
+ locale_health = data.get(locale.code)
+
+ return {
+ "community_health_dates": dates,
+ "community_health_scores": locale_health["chs"]
+ if locale_health
+ else [None] * 12,
+ }
+
+
def get_insights(locale=None, project=None):
"""Get data required by the Insights tab."""
start_date = get_insight_start_date()
@@ -361,8 +415,8 @@ def get_insights(locale=None, project=None):
"new_suggestions": [x["new_suggestions_sum"] for x in insights],
},
"pretranslation_quality": {
- "approval_rate": json.dumps([get_approval_rate(x) for x in insights]),
- "chrf_score": json.dumps([get_chrf_score(x) for x in insights]),
+ "approval_rate": [get_approval_rate(x) for x in insights],
+ "chrf_score": [get_chrf_score(x) for x in insights],
"approved": [x["pretranslations_approved_sum"] for x in insights],
"rejected": [x["pretranslations_rejected_sum"] for x in insights],
"new": [x["pretranslations_new_sum"] for x in insights],
@@ -462,5 +516,15 @@ def get_global_pretranslation_quality(category, id):
return {
"dates": sorted(list({convert_to_unix_time(x["month"]) for x in actions})),
- "dataset": json.dumps([v for _, v in data.items()]),
+ "dataset": [v for _, v in data.items()],
+ }
+
+
+def get_global_locale_health_insights(locales):
+ """Get locale health data required by Global Insights tab."""
+ dates, data = get_locale_health_data(locales)
+
+ return {
+ "dates": dates,
+ "dataset": list(data.values()),
}
diff --git a/pontoon/insights/views.py b/pontoon/insights/views.py
index 68f5e9568e..b79fbb6a8c 100644
--- a/pontoon/insights/views.py
+++ b/pontoon/insights/views.py
@@ -3,22 +3,194 @@
from dateutil.relativedelta import relativedelta
from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
from django.core.cache import cache
-from django.core.exceptions import ImproperlyConfigured
-from django.shortcuts import render
+from django.core.exceptions import ImproperlyConfigured, PermissionDenied
+from django.shortcuts import redirect, render
from django.utils import timezone
-from pontoon.insights.utils import get_global_pretranslation_quality
+from pontoon.base.forms import UserInsightsDashboardConfigForm
+from pontoon.base.models.locale import Locale
+from pontoon.insights.chs import KEY_PROJECT_SLUGS
+from pontoon.insights.models import LocaleHealthSnapshot
+from pontoon.insights.utils import (
+ get_global_locale_health_insights,
+ get_global_pretranslation_quality,
+)
+from pontoon.settings.base import (
+ ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ ACTIVE_CONTRIBUTOR_POINTS,
+ ALL_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ ALL_CONTRIBUTOR_POINTS,
+ COMPLETION_POINTS,
+ ENABLED_PROJECT_POINTS,
+ MANAGER_PEOPLE_THRESHOLD,
+ MANAGER_POINTS,
+ NEW_SIGNUP_PEOPLE_THRESHOLD,
+ NEW_SIGNUP_POINTS,
+ TRANSLATOR_PEOPLE_THRESHOLD,
+ TRANSLATOR_POINTS,
+)
log = logging.getLogger(__name__)
+CHS_BASE_METRICS = [
+ "active_managers",
+ "active_translators",
+ "active_contributors",
+ "all_contributors",
+ "new_signups",
+ "key_projects_enabled",
+ "completion",
+ "chs",
+]
+
+CHS_SCORE_METRICS = [
+ "key_projects_enabled_score",
+ "active_managers_score",
+ "active_translators_score",
+ "active_contributors_score",
+ "all_contributors_score",
+ "new_signups_score",
+ "completion_score",
+ "chs",
+]
+
+CHS_COLUMNS = {
+ "active_managers": {
+ "base_threshold": MANAGER_PEOPLE_THRESHOLD,
+ "score_threshold": MANAGER_POINTS,
+ },
+ "active_translators": {
+ "base_threshold": TRANSLATOR_PEOPLE_THRESHOLD,
+ "score_threshold": TRANSLATOR_POINTS,
+ },
+ "active_contributors": {
+ "base_threshold": ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ "score_threshold": ACTIVE_CONTRIBUTOR_POINTS,
+ },
+ "all_contributors": {
+ "base_threshold": ALL_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ "score_threshold": ALL_CONTRIBUTOR_POINTS,
+ },
+ "new_signups": {
+ "base_threshold": NEW_SIGNUP_PEOPLE_THRESHOLD,
+ "score_threshold": NEW_SIGNUP_POINTS,
+ },
+ "key_projects_enabled": {
+ "base_threshold": len(KEY_PROJECT_SLUGS),
+ "score_threshold": ENABLED_PROJECT_POINTS,
+ },
+ "completion": {
+ "base_threshold": 100,
+ "percent": True,
+ "score_threshold": COMPLETION_POINTS,
+ },
+ "chs": {"base_threshold": 100},
+}
+
+
+def get_monthly_snapshots(locales, date):
+ month_start = date.replace(day=1)
+ next_month_start = month_start + relativedelta(months=1)
+
+ snapshots = LocaleHealthSnapshot.objects.filter(
+ locale__in=locales,
+ created_at__gte=month_start,
+ created_at__lt=next_month_start,
+ ).order_by("locale_id", "-created_at")
+
+ latest = {}
+ for snapshot in snapshots:
+ latest.setdefault(snapshot.locale_id, snapshot)
+
+ return latest
+
+
+def get_monthly_snapshot_deltas(current_snapshots, previous_snapshots, metrics):
+ deltas = {}
+ for locale_id, curr_snapshot in current_snapshots.items():
+ prev_snapshot = previous_snapshots.get(locale_id)
+ locale_deltas = {}
+ for column_key in metrics:
+ if prev_snapshot is None:
+ locale_deltas[column_key] = None
+ continue
+ curr_value = getattr(curr_snapshot, column_key)
+ prev_value = getattr(prev_snapshot, column_key)
+ locale_deltas[column_key] = curr_value - prev_value
+ deltas[locale_id] = locale_deltas
+
+ return deltas
+
+
+@login_required(redirect_field_name="", login_url="/403")
+def insights_config(request):
+ """Configure which locales appear on the Insights dashboard."""
+ if not settings.ENABLE_INSIGHTS:
+ raise ImproperlyConfigured("ENABLE_INSIGHTS variable not set in settings.")
+
+ user = request.user
+ profile = user.profile
+
+ if not user.is_staff:
+ raise PermissionDenied
+
+ if request.method == "POST":
+ dashboard_locales_form = UserInsightsDashboardConfigForm(
+ request.POST, instance=profile
+ )
+
+ if dashboard_locales_form.is_valid():
+ dashboard_locales_form.save()
+ messages.success(request, "Configuration saved.")
+ return redirect("pontoon.insights")
+
+ dashboard_locales = profile.dashboard_locales
+
+ locales = Locale.objects.visible()
+ selected_locales = locales.filter(pk__in=dashboard_locales)
+ available_locales = locales.exclude(pk__in=dashboard_locales)
+ return render(
+ request,
+ "insights/config.html",
+ {
+ "available_locales": available_locales,
+ "selected_locales": selected_locales,
+ },
+ )
+
+
+@login_required(redirect_field_name="", login_url="/403")
def insights(request):
"""Insights page."""
+
if not settings.ENABLE_INSIGHTS:
raise ImproperlyConfigured("ENABLE_INSIGHTS variable not set in settings.")
+ user = request.user
+ profile = user.profile
+
+ if not user.is_staff:
+ raise PermissionDenied
+
+ dashboard_locales = profile.dashboard_locales
+ locales = Locale.objects.filter(pk__in=dashboard_locales).order_by("code")
+
+ current_anchor = timezone.now().date()
+ previous_anchor = current_anchor.replace(day=1) - relativedelta(days=1)
+ current_snapshots = get_monthly_snapshots(locales, current_anchor)
+ previous_snapshots = get_monthly_snapshots(locales, previous_anchor)
+ snapshot_base_deltas = get_monthly_snapshot_deltas(
+ current_snapshots, previous_snapshots, CHS_BASE_METRICS
+ )
+ snapshot_score_deltas = get_monthly_snapshot_deltas(
+ current_snapshots, previous_snapshots, CHS_SCORE_METRICS
+ )
+
# Cannot use cache.get_or_set(), because it always calls the slow function
# get_global_pretranslation_quality(). The reason we use cache in first place is to
# avoid that.
@@ -41,13 +213,21 @@ def insights(request):
project_pt_key, project_pretranslation_quality, settings.VIEW_CACHE_TIMEOUT
)
+ global_locale_health_insights = get_global_locale_health_insights(locales)
+
return render(
request,
"insights/insights.html",
{
"start_date": timezone.now() - relativedelta(years=1),
"end_date": timezone.now(),
+ "locales": locales,
+ "current_snapshots": current_snapshots,
+ "snapshot_base_deltas": snapshot_base_deltas,
+ "snapshot_score_deltas": snapshot_score_deltas,
+ "columns": CHS_COLUMNS,
"team_pretranslation_quality": team_pretranslation_quality,
"project_pretranslation_quality": project_pretranslation_quality,
+ "global_locale_health_insights": global_locale_health_insights,
},
)
diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py
index b3114234e7..97c169b84c 100644
--- a/pontoon/settings/base.py
+++ b/pontoon/settings/base.py
@@ -460,8 +460,13 @@ def _default_from_email():
},
"insights": {
"source_filenames": (
+ "css/heading_info.css",
+ "css/table.css",
+ "css/request.css",
+ "css/multiple_item_selector.css",
"css/insights_charts.css",
"css/insights.css",
+ "css/config.css",
),
"output_filename": "css/insights.min.css",
},
@@ -619,6 +624,8 @@ def _default_from_email():
"source_filenames": (
"js/lib/chart.umd.min.js",
"js/lib/chartjs-adapter-date-fns.bundle.min.js",
+ "js/table.js",
+ "js/multiple_item_selector.js",
"js/insights_charts.js",
"js/insights.js",
),
@@ -1212,6 +1219,11 @@ def account_username(user):
# email will be sent.
MONTHLY_ACTIVITY_SUMMARY_DAY = os.environ.get("MONTHLY_ACTIVITY_SUMMARY_DAY", 1)
+# Integer representing a day of the month on which the Community Health Score
+# snapshots are collected via collect_chs_snapshots().
+MONTHLY_CHS_SNAPSHOTS_DAY = os.environ.get("MONTHLY_CHS_SNAPSHOTS_DAY", 1)
+
+
# Number of days after user registration to send the 2nd onboarding email
ONBOARDING_EMAIL_2_DELAY = os.environ.get("ONBOARDING_EMAIL_2_DELAY", 2)
@@ -1255,6 +1267,35 @@ def account_username(user):
map(int, os.environ.get("BADGES_PROMOTION_THRESHOLDS", "1, 2, 5").split(","))
)
+# Used for Community Health Score calculations
+MANAGER_STRING_THRESHOLD = int(os.environ.get("MANAGER_STRING_THRESHOLD", 500))
+TRANSLATOR_STRING_THRESHOLD = int(os.environ.get("TRANSLATOR_STRING_THRESHOLD", 400))
+ACTIVE_CONTRIBUTOR_STRING_THRESHOLD = int(
+ os.environ.get("ACTIVE_CONTRIBUTOR_STRING_THRESHOLD", 200)
+)
+ALL_CONTRIBUTOR_STRING_THRESHOLD = int(
+ os.environ.get("ALL_CONTRIBUTOR_STRING_THRESHOLD", 200)
+)
+NEW_SIGNUP_STRING_THRESHOLD = int(os.environ.get("NEW_SIGNUP_STRING_THRESHOLD", 100))
+
+MANAGER_PEOPLE_THRESHOLD = int(os.environ.get("MANAGER_PEOPLE_THRESHOLD", 1))
+TRANSLATOR_PEOPLE_THRESHOLD = int(os.environ.get("TRANSLATOR_PEOPLE_THRESHOLD", 2))
+ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD = int(
+ os.environ.get("ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD", 2)
+)
+ALL_CONTRIBUTOR_PEOPLE_THRESHOLD = int(
+ os.environ.get("ALL_CONTRIBUTOR_PEOPLE_THRESHOLD", 2)
+)
+NEW_SIGNUP_PEOPLE_THRESHOLD = int(os.environ.get("NEW_SIGNUP_PEOPLE_THRESHOLD", 2))
+
+MANAGER_POINTS = float(os.environ.get("MANAGER_POINTS", 20.0))
+TRANSLATOR_POINTS = float(os.environ.get("TRANSLATOR_POINTS", 15.0))
+ACTIVE_CONTRIBUTOR_POINTS = float(os.environ.get("ACTIVE_CONTRIBUTOR_POINTS", 6.0))
+ALL_CONTRIBUTOR_POINTS = float(os.environ.get("ALL_CONTRIBUTOR_POINTS", 4.0))
+NEW_SIGNUP_POINTS = float(os.environ.get("NEW_SIGNUP_POINTS", 5.0))
+ENABLED_PROJECT_POINTS = float(os.environ.get("ENABLED_PROJECT_POINTS", 4.0))
+COMPLETION_POINTS = float(os.environ.get("COMPLETION_POINTS", 46.0))
+
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Used in the header of the Terminology (.TBX) files.
diff --git a/pontoon/teams/views.py b/pontoon/teams/views.py
index 0dd72831ac..141decc419 100644
--- a/pontoon/teams/views.py
+++ b/pontoon/teams/views.py
@@ -54,7 +54,7 @@
)
from pontoon.base.utils import require_AJAX
from pontoon.contributors.views import ContributorsMixin
-from pontoon.insights.utils import get_locale_insights
+from pontoon.insights.utils import get_locale_health_insights, get_locale_insights
from pontoon.teams.forms import LocaleRequestForm
from ..base.models.project import ProjectQuerySet
@@ -209,7 +209,9 @@ def ajax_insights(request, locale):
key = f"/{__name__}/{locale.code}/insights"
insights = cache.get(key)
if not insights:
- insights = get_locale_insights(Q(locale=locale))
+ locale_insights = get_locale_insights(Q(locale=locale))
+ locale_health_insights = get_locale_health_insights(locale)
+ insights = locale_insights | locale_health_insights
cache.set(key, insights, settings.VIEW_CACHE_TIMEOUT)
return render(request, "teams/includes/insights.html", insights)
diff --git a/pontoon/test/fixtures/base.py b/pontoon/test/fixtures/base.py
index 383f91657b..8146e30f52 100644
--- a/pontoon/test/fixtures/base.py
+++ b/pontoon/test/fixtures/base.py
@@ -11,6 +11,7 @@ def admin():
return factories.UserFactory.create(
username="admin",
is_superuser=True,
+ is_staff=True,
)
+ Community health score + {{ + Tooltip.display( + intro='Community health score at a particular point in time.', + items=[{ + 'class': 'current-month', + 'name': 'Current month', + 'definition': 'Community health score calculated for a specific month.', + }] + ) + }} +
+
@@ -178,11 +206,11 @@
@@ -220,10 +248,10 @@
@@ -265,11 +293,11 @@
diff --git a/pontoon/insights/templates/insights/widgets/locale_list.html b/pontoon/insights/templates/insights/widgets/locale_list.html
new file mode 100644
index 0000000000..98c9a4f3f0
--- /dev/null
+++ b/pontoon/insights/templates/insights/widgets/locale_list.html
@@ -0,0 +1,105 @@
+{% macro header() %}
+
+
+
+ Locale
+ Managers
+ Translators
+
+ Contr. 1
+
+
+ Contr. 2
+
+ New
+
+ Projects
+
+ Completion
+ Score
+
+
+
+
+{% endmacro %}
+
+{% macro delta_span(delta, percent=False) %}
+ {% if delta is not none %}
+
+
+ {% if delta != 0 %}
+ {{ '+' if delta > 0 }}{{ delta }}{{ '%' if percent }}
+ {% endif %}
+
+ {% endif %}
+{% endmacro %}
+
+{% macro value_cell(base_value, base_delta=None, base_threshold=None,
+ score_value=None, score_delta=None, score_threshold=None,
+ percent=False, single=False) %}
+
+ {% if single %}
+
+ {{ delta_span(base_delta, percent) }}
+
+ {{ base_value }}{{ '%' if percent }}
+
+
+ {% else %}
+
+ {{ delta_span(base_delta, percent) }}
+
+ {{ base_value }}{{ '%' if percent }}
+
+
+
+ {{ delta_span(score_delta, false) }}
+
+ {{ score_value }}
+
+
+ {% endif %}
+
+{% endmacro %}
+
+{% macro item(locale, main_link, curr_snapshot=None, base_deltas=None, score_deltas=None, columns=None, class='limited') %}
+
+
+
+
+ {% if curr_snapshot %}
+ {% for field, column in columns.items() %}
+ {% if field == 'chs' %}
+ {{ value_cell(
+ base_value=curr_snapshot[field],
+ base_delta=base_deltas[field] if base_deltas else None,
+ base_threshold=column.base_threshold,
+ single=True) }}
+ {% else %}
+ {{ value_cell(
+ base_value=curr_snapshot[field],
+ base_delta=base_deltas[field] if base_deltas else None,
+ base_threshold=column.base_threshold,
+ score_value=curr_snapshot[field + '_score'],
+ score_delta=score_deltas[field + '_score'] if score_deltas else None,
+ score_threshold=column.score_threshold,
+ percent=column.percent) }}
+ {% endif %}
+ {% endfor %}
+ {% else %}
+
+ No snapshot this month
+
+ {% endif %}
+
+{% endmacro %}
+
+{% macro footer() %}
+
+
+
+
+{% endmacro %}
diff --git a/pontoon/insights/tests/test_tasks.py b/pontoon/insights/tests/test_tasks.py
index 5b57e99985..13753f566b 100644
--- a/pontoon/insights/tests/test_tasks.py
+++ b/pontoon/insights/tests/test_tasks.py
@@ -8,6 +8,15 @@
from django.utils import timezone
from pontoon.actionlog.models import ActionLog
+from pontoon.base.models import Locale
+from pontoon.insights.chs import (
+ KEY_PROJECT_SLUGS,
+ build_chs_snapshots,
+ compute_chs,
+ get_completion_by_locale,
+ get_contributor_metrics_by_locale,
+ get_key_projects_enabled_by_locale,
+)
from pontoon.insights.tasks import (
Activity,
count_activities,
@@ -18,9 +27,13 @@
)
from pontoon.test.factories import (
EntityFactory,
+ GroupFactory,
+ ProjectFactory,
ProjectLocaleFactory,
+ ResourceFactory,
TranslatedResourceFactory,
TranslationFactory,
+ UserFactory,
)
@@ -271,3 +284,278 @@ def test_count_activities_chrf_score_zero_included(
result = count_activities(now)
activity = result[project_locale_a.pk]
assert 0.0 in activity.pretranslations_chrf_scores
+
+
+def test_compute_chs():
+ # full metric chs activity
+ assert compute_chs(
+ {
+ "active_managers": 1,
+ "active_translators": 2,
+ "active_contributors": 2,
+ "all_contributors": 2,
+ "new_signups": 2,
+ "key_projects_enabled": len(KEY_PROJECT_SLUGS),
+ "completion": 100.0,
+ }
+ ) == {
+ "active_managers_score": 20.0,
+ "active_translators_score": 15.0,
+ "active_contributors_score": 6.0,
+ "all_contributors_score": 4.0,
+ "new_signups_score": 5.0,
+ "key_projects_enabled_score": 4.0,
+ "completion_score": 46.0,
+ "chs": 100.0,
+ }
+
+ # half chs metric activity
+ assert compute_chs(
+ {
+ "active_managers": 1,
+ "active_translators": 1,
+ "active_contributors": 1,
+ "all_contributors": 1,
+ "new_signups": 1,
+ "key_projects_enabled": len(KEY_PROJECT_SLUGS),
+ "completion": 50.0,
+ }
+ ) == {
+ "active_managers_score": 20.0,
+ "active_translators_score": 7.5,
+ "active_contributors_score": 3.0,
+ "all_contributors_score": 2.0,
+ "new_signups_score": 2.5,
+ "key_projects_enabled_score": 4,
+ "completion_score": 23.0,
+ "chs": 62,
+ }
+
+ # no chs metric activity
+ assert compute_chs(
+ {
+ "active_managers": 0,
+ "active_translators": 0,
+ "active_contributors": 0,
+ "all_contributors": 0,
+ "new_signups": 0,
+ "key_projects_enabled": 0,
+ "completion": 0.0,
+ }
+ ) == {
+ "active_managers_score": 0,
+ "active_translators_score": 0,
+ "active_contributors_score": 0,
+ "all_contributors_score": 0,
+ "new_signups_score": 0,
+ "key_projects_enabled_score": 0.0,
+ "completion_score": 0.0,
+ "chs": 0.0,
+ }
+
+
+@pytest.mark.django_db
+def test_get_completion_by_locale(locale_a, locale_b):
+ key_project = ProjectFactory.create(slug="firefox", name="Firefox", repositories=[])
+ key_resource_a = ResourceFactory.create(project=key_project, path="key_a.po")
+ key_resource_b = ResourceFactory.create(project=key_project, path="key_b.po")
+ key_resource_c = ResourceFactory.create(project=key_project, path="key_c.po")
+ # A non-key project must not contribute to the completion score.
+ other_resource = ResourceFactory.create(path="other.po")
+
+ TranslatedResourceFactory.create(
+ resource=key_resource_a,
+ locale=locale_a,
+ total_strings=6,
+ approved_strings=5,
+ strings_with_warnings=0,
+ )
+ TranslatedResourceFactory.create(
+ resource=key_resource_b,
+ locale=locale_a,
+ total_strings=4,
+ approved_strings=2,
+ strings_with_warnings=1,
+ )
+ TranslatedResourceFactory.create(
+ resource=key_resource_c,
+ locale=locale_b,
+ total_strings=0,
+ )
+ TranslatedResourceFactory.create(
+ resource=other_resource,
+ locale=locale_a,
+ total_strings=100,
+ approved_strings=100,
+ strings_with_warnings=0,
+ )
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk, locale_b.pk])
+ assert get_completion_by_locale(locales) == {
+ locale_a.pk: 80.0,
+ locale_b.pk: 0.0,
+ }
+
+
+@pytest.mark.django_db
+def test_get_key_projects_enabled_by_locale(locale_a, locale_b):
+ enabled_a = ProjectFactory.create(slug="firefox", name="Firefox", repositories=[])
+ enabled_b = ProjectFactory.create(
+ slug="firefox-for-ios", name="Firefox for iOS", repositories=[]
+ )
+ disabled = ProjectFactory.create(
+ slug="firefox-for-android",
+ name="Firefox for Android",
+ repositories=[],
+ disabled=True,
+ )
+ non_key = ProjectFactory.create(
+ slug="not-a-key-project", name="Other", repositories=[]
+ )
+
+ ProjectLocaleFactory.create(project=enabled_a, locale=locale_a)
+ ProjectLocaleFactory.create(project=enabled_b, locale=locale_a)
+
+ ProjectLocaleFactory.create(project=disabled, locale=locale_a)
+ ProjectLocaleFactory.create(project=non_key, locale=locale_a)
+
+ ProjectLocaleFactory.create(project=non_key, locale=locale_b)
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk, locale_b.pk])
+ assert get_key_projects_enabled_by_locale(locales, KEY_PROJECT_SLUGS) == {
+ locale_a.pk: 2,
+ }
+
+
+@pytest.mark.django_db
+def test_get_contributor_metrics_by_locale(locale_a, locale_b, resource_a):
+ now = timezone.now()
+ in_12_month_window = now - relativedelta(days=1)
+ out_12_month_window = now - relativedelta(months=13, days=1)
+
+ managers_group = GroupFactory.create(name="managers")
+ translators_group = GroupFactory.create(name="translators")
+ locale_a.managers_group = managers_group
+ locale_a.translators_group = translators_group
+ locale_a.save()
+
+ manager = UserFactory.create(username="manager")
+ translator = UserFactory.create(username="translator")
+ contributor_a = UserFactory.create(username="contributor_a")
+ contributor_b = UserFactory.create(username="contributor_b")
+ managers_group.user_set.add(manager)
+ translators_group.user_set.add(translator)
+
+ # Managers and translators need one authored translation to appear in the
+ # results, and cross their thresholds through review actions
+ manager_translation = TranslationFactory.create(
+ entity__resource=resource_a,
+ locale=locale_a,
+ user=manager,
+ date=in_12_month_window,
+ )
+ translator_translation = TranslationFactory.create(
+ entity__resource=resource_a,
+ locale=locale_a,
+ user=translator,
+ date=in_12_month_window,
+ )
+ ActionLog.objects.bulk_create(
+ [
+ ActionLog(
+ action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
+ created_at=in_12_month_window,
+ performed_by=manager,
+ translation=manager_translation,
+ )
+ for _ in range(501)
+ ]
+ + [
+ ActionLog(
+ action_type=ActionLog.ActionType.TRANSLATION_APPROVED,
+ created_at=in_12_month_window,
+ performed_by=translator,
+ translation=translator_translation,
+ )
+ for _ in range(401)
+ ]
+ )
+
+ # An active contributor crosses the active, all-contributor, and new-signup
+ # thresholds at once
+ for entity in EntityFactory.create_batch(size=201, resource=resource_a):
+ TranslationFactory.create(
+ entity=entity,
+ locale=locale_a,
+ user=contributor_a,
+ date=in_12_month_window,
+ approved=True,
+ )
+
+ # A contributor below every threshold is counted nowhere
+ TranslationFactory.create(
+ entity__resource=resource_a,
+ locale=locale_a,
+ user=contributor_b,
+ date=in_12_month_window,
+ approved=True,
+ )
+
+ # contributions made outside 12 month window - current month
+ # are not counted
+ for entity in EntityFactory.create_batch(size=201, resource=resource_a):
+ TranslationFactory.create(
+ entity=entity,
+ locale=locale_b,
+ user=contributor_b,
+ date=out_12_month_window,
+ approved=True,
+ )
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk, locale_b.pk])
+ assert get_contributor_metrics_by_locale(locales, now) == {
+ locale_a.pk: {
+ "active_managers": 1,
+ "active_translators": 1,
+ "active_contributors": 1,
+ "all_contributors": 1,
+ "new_signups": 1,
+ },
+ locale_b.pk: {
+ "active_managers": 0,
+ "active_translators": 0,
+ "active_contributors": 0,
+ "all_contributors": 0,
+ "new_signups": 0,
+ },
+ }
+
+
+@pytest.mark.django_db
+def test_build_chs_snapshots(locale_a):
+ key_project = ProjectFactory.create(slug="firefox", name="Firefox", repositories=[])
+ resource = ResourceFactory.create(project=key_project, path="firefox.po")
+ ProjectLocaleFactory.create(project=key_project, locale=locale_a)
+ TranslatedResourceFactory.create(
+ resource=resource,
+ locale=locale_a,
+ total_strings=10,
+ approved_strings=8,
+ strings_with_warnings=0,
+ )
+
+ locales = Locale.objects.filter(pk__in=[locale_a.pk])
+ snapshots = build_chs_snapshots(locales)
+
+ # Only visible locales get snapshots
+ assert {snapshot.locale_id for snapshot in snapshots} == {locale_a.pk}
+ assert len(snapshots) == 1
+
+ snapshot = snapshots[0]
+ assert snapshot.completion == 80.0
+ assert snapshot.key_projects_enabled == 1
+ assert snapshot.active_managers == 0
+ assert snapshot.active_contributors == 0
+ assert snapshot.completion_score == 36.8
+ assert snapshot.key_projects_enabled_score == 0.57
+ assert snapshot.chs == 37.37
diff --git a/pontoon/insights/tests/test_views.py b/pontoon/insights/tests/test_views.py
index db5b919991..687cc16c5b 100644
--- a/pontoon/insights/tests/test_views.py
+++ b/pontoon/insights/tests/test_views.py
@@ -1,5 +1,3 @@
-import json
-
from dataclasses import dataclass
from datetime import datetime, timezone
from http import HTTPStatus
@@ -45,10 +43,10 @@ class MonthlyQualityEntry:
@pytest.mark.django_db
-def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
+def test_default_empty(client_superuser, clear_cache, locale_a, project_a, user_a):
url = reverse("pontoon.insights")
with patch.object(views, "render", wraps=render) as mock_render:
- response = client.get(url)
+ response = client_superuser.get(url)
assert response.status_code == HTTPStatus.OK
response_context = mock_render.call_args[0][2]
@@ -56,7 +54,7 @@ def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
end_date = response_context["end_date"]
assert start_date < end_date <= datetime.now(timezone.utc)
team_pretranslation_quality = response_context["team_pretranslation_quality"]
- assert json.loads(team_pretranslation_quality["dataset"]) == [
+ assert team_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
@@ -76,7 +74,7 @@ def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
}
]
project_pretranslation_quality = response_context["project_pretranslation_quality"]
- assert json.loads(project_pretranslation_quality["dataset"]) == [
+ assert project_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
@@ -98,7 +96,9 @@ def test_default_empty(client, clear_cache, locale_a, project_a, user_a):
@pytest.mark.django_db
-def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, user_a):
+def test_default_with_data(
+ client_superuser, clear_cache, tm_user, locale_a, project_a, user_a
+):
entries = [
MonthlyQualityEntry(months_ago=0, approved=1, rejected=0),
MonthlyQualityEntry(months_ago=1, approved=0, rejected=1),
@@ -135,7 +135,7 @@ def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, us
url = reverse("pontoon.insights")
with patch.object(views, "render", wraps=render) as mock_render:
- response = client.get(url)
+ response = client_superuser.get(url)
assert response.status_code == HTTPStatus.OK
response_context = mock_render.call_args[0][2]
@@ -143,7 +143,7 @@ def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, us
end_date = response_context["end_date"]
assert start_date < end_date <= datetime.now(timezone.utc)
team_pretranslation_quality = response_context["team_pretranslation_quality"]
- assert json.loads(team_pretranslation_quality["dataset"]) == [
+ assert team_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
@@ -180,7 +180,7 @@ def test_default_with_data(client, clear_cache, tm_user, locale_a, project_a, us
},
]
project_pretranslation_quality = response_context["project_pretranslation_quality"]
- assert json.loads(project_pretranslation_quality["dataset"]) == [
+ assert project_pretranslation_quality["dataset"] == [
{
"name": "All",
"approval_rate": [
diff --git a/pontoon/insights/urls.py b/pontoon/insights/urls.py
index adf15b015e..bc26fd96aa 100644
--- a/pontoon/insights/urls.py
+++ b/pontoon/insights/urls.py
@@ -1,13 +1,19 @@
from django.urls import path
-from . import views
+from pontoon.insights.views import insights, insights_config
urlpatterns = [
# Insights page
path(
"insights/",
- views.insights,
+ insights,
name="pontoon.insights",
),
+ # Insights config page
+ path(
+ "insights/config",
+ insights_config,
+ name="pontoon.insights.config",
+ ),
]
diff --git a/pontoon/insights/utils.py b/pontoon/insights/utils.py
index 4daceb8027..9306b03cb6 100644
--- a/pontoon/insights/utils.py
+++ b/pontoon/insights/utils.py
@@ -1,5 +1,3 @@
-import json
-
from datetime import timedelta
from dateutil.relativedelta import relativedelta
@@ -12,6 +10,7 @@
from pontoon.actionlog.models import ActionLog
from pontoon.base.utils import convert_to_unix_time
from pontoon.insights.models import (
+ LocaleHealthSnapshot,
LocaleInsightsSnapshot,
ProjectLocaleInsightsSnapshot,
active_users_default,
@@ -90,7 +89,7 @@ def get_time_to_review_12_month_avg(category, query_filters=None):
value = None
times_to_review_12_month_avg.insert(0, value)
- return json.dumps(times_to_review_12_month_avg)
+ return times_to_review_12_month_avg
def get_approval_rate(insight):
@@ -115,6 +114,51 @@ def get_chrf_score(insight):
return round(score, 2)
+def get_locale_health_data(locales):
+ """
+ Get locale health data required by Locale Insights and Global Insights tabs.
+
+ Note: The community health score is computed for a whole month, so the current
+ month's score will not be complete. Therefore, the most recent score available
+ will always be of the previous month. Subsequently, the 12 month window of
+ scores will be displayed starting from the previous month based on the user's
+ date.
+ """
+ end_month = timezone.now().date().replace(day=1) - relativedelta(months=1)
+ months = [end_month - relativedelta(months=11 - i) for i in range(12)]
+ indices = {month: i for i, month in enumerate(months)}
+
+ health_snapshots = (
+ LocaleHealthSnapshot.objects.filter(
+ created_at__gte=months[0], locale__in=locales
+ )
+ .annotate(month=TruncMonth("created_at"))
+ .order_by("created_at")
+ .values("month", "locale__code", "locale__name", "chs")
+ )
+
+ data = {}
+
+ for snapshot in health_snapshots:
+ month = snapshot["month"]
+ if month not in indices:
+ continue
+
+ locale_code = snapshot["locale__code"]
+ item = data.setdefault(
+ locale_code,
+ {
+ "name": f"{snapshot['locale__name']} ยท {locale_code}",
+ "chs": [None] * 12,
+ },
+ )
+ item["chs"][indices[month]] = float(snapshot["chs"])
+
+ dates = [convert_to_unix_time(month) for month in months]
+
+ return dates, data
+
+
def get_locale_insights(query_filters=None):
"""Get data required by the Locale Insights tab.
@@ -223,21 +267,17 @@ def get_locale_insights(query_filters=None):
"unreviewed_lifespans": [
x["unreviewed_lifespan_avg"].days for x in insights
],
- "time_to_review_suggestions": json.dumps(
- [
- get_time_to_review(x["time_to_review_suggestions_avg"])
- for x in insights
- ]
- ),
+ "time_to_review_suggestions": [
+ get_time_to_review(x["time_to_review_suggestions_avg"])
+ for x in insights
+ ],
"time_to_review_suggestions_12_month_avg": get_time_to_review_12_month_avg(
"suggestions", query_filters
),
- "time_to_review_pretranslations": json.dumps(
- [
- get_time_to_review(x["time_to_review_pretranslations_avg"])
- for x in insights
- ]
- ),
+ "time_to_review_pretranslations": [
+ get_time_to_review(x["time_to_review_pretranslations_avg"])
+ for x in insights
+ ],
"time_to_review_pretranslations_12_month_avg": get_time_to_review_12_month_avg(
"pretranslations", query_filters
),
@@ -255,8 +295,8 @@ def get_locale_insights(query_filters=None):
"new_suggestions": [x["new_suggestions_sum"] for x in insights],
},
"pretranslation_quality": {
- "approval_rate": json.dumps([get_approval_rate(x) for x in insights]),
- "chrf_score": json.dumps([get_chrf_score(x) for x in insights]),
+ "approval_rate": [get_approval_rate(x) for x in insights],
+ "chrf_score": [get_chrf_score(x) for x in insights],
"approved": [x["pretranslations_approved_sum"] for x in insights],
"rejected": [x["pretranslations_rejected_sum"] for x in insights],
"new": [x["pretranslations_new_sum"] for x in insights],
@@ -267,6 +307,20 @@ def get_locale_insights(query_filters=None):
return output
+def get_locale_health_insights(locale):
+ """Get locale health data required by Locale Insights tab."""
+ dates, data = get_locale_health_data([locale])
+
+ locale_health = data.get(locale.code)
+
+ return {
+ "community_health_dates": dates,
+ "community_health_scores": locale_health["chs"]
+ if locale_health
+ else [None] * 12,
+ }
+
+
def get_insights(locale=None, project=None):
"""Get data required by the Insights tab."""
start_date = get_insight_start_date()
@@ -361,8 +415,8 @@ def get_insights(locale=None, project=None):
"new_suggestions": [x["new_suggestions_sum"] for x in insights],
},
"pretranslation_quality": {
- "approval_rate": json.dumps([get_approval_rate(x) for x in insights]),
- "chrf_score": json.dumps([get_chrf_score(x) for x in insights]),
+ "approval_rate": [get_approval_rate(x) for x in insights],
+ "chrf_score": [get_chrf_score(x) for x in insights],
"approved": [x["pretranslations_approved_sum"] for x in insights],
"rejected": [x["pretranslations_rejected_sum"] for x in insights],
"new": [x["pretranslations_new_sum"] for x in insights],
@@ -462,5 +516,15 @@ def get_global_pretranslation_quality(category, id):
return {
"dates": sorted(list({convert_to_unix_time(x["month"]) for x in actions})),
- "dataset": json.dumps([v for _, v in data.items()]),
+ "dataset": [v for _, v in data.items()],
+ }
+
+
+def get_global_locale_health_insights(locales):
+ """Get locale health data required by Global Insights tab."""
+ dates, data = get_locale_health_data(locales)
+
+ return {
+ "dates": dates,
+ "dataset": list(data.values()),
}
diff --git a/pontoon/insights/views.py b/pontoon/insights/views.py
index 68f5e9568e..b79fbb6a8c 100644
--- a/pontoon/insights/views.py
+++ b/pontoon/insights/views.py
@@ -3,22 +3,194 @@
from dateutil.relativedelta import relativedelta
from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
from django.core.cache import cache
-from django.core.exceptions import ImproperlyConfigured
-from django.shortcuts import render
+from django.core.exceptions import ImproperlyConfigured, PermissionDenied
+from django.shortcuts import redirect, render
from django.utils import timezone
-from pontoon.insights.utils import get_global_pretranslation_quality
+from pontoon.base.forms import UserInsightsDashboardConfigForm
+from pontoon.base.models.locale import Locale
+from pontoon.insights.chs import KEY_PROJECT_SLUGS
+from pontoon.insights.models import LocaleHealthSnapshot
+from pontoon.insights.utils import (
+ get_global_locale_health_insights,
+ get_global_pretranslation_quality,
+)
+from pontoon.settings.base import (
+ ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ ACTIVE_CONTRIBUTOR_POINTS,
+ ALL_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ ALL_CONTRIBUTOR_POINTS,
+ COMPLETION_POINTS,
+ ENABLED_PROJECT_POINTS,
+ MANAGER_PEOPLE_THRESHOLD,
+ MANAGER_POINTS,
+ NEW_SIGNUP_PEOPLE_THRESHOLD,
+ NEW_SIGNUP_POINTS,
+ TRANSLATOR_PEOPLE_THRESHOLD,
+ TRANSLATOR_POINTS,
+)
log = logging.getLogger(__name__)
+CHS_BASE_METRICS = [
+ "active_managers",
+ "active_translators",
+ "active_contributors",
+ "all_contributors",
+ "new_signups",
+ "key_projects_enabled",
+ "completion",
+ "chs",
+]
+
+CHS_SCORE_METRICS = [
+ "key_projects_enabled_score",
+ "active_managers_score",
+ "active_translators_score",
+ "active_contributors_score",
+ "all_contributors_score",
+ "new_signups_score",
+ "completion_score",
+ "chs",
+]
+
+CHS_COLUMNS = {
+ "active_managers": {
+ "base_threshold": MANAGER_PEOPLE_THRESHOLD,
+ "score_threshold": MANAGER_POINTS,
+ },
+ "active_translators": {
+ "base_threshold": TRANSLATOR_PEOPLE_THRESHOLD,
+ "score_threshold": TRANSLATOR_POINTS,
+ },
+ "active_contributors": {
+ "base_threshold": ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ "score_threshold": ACTIVE_CONTRIBUTOR_POINTS,
+ },
+ "all_contributors": {
+ "base_threshold": ALL_CONTRIBUTOR_PEOPLE_THRESHOLD,
+ "score_threshold": ALL_CONTRIBUTOR_POINTS,
+ },
+ "new_signups": {
+ "base_threshold": NEW_SIGNUP_PEOPLE_THRESHOLD,
+ "score_threshold": NEW_SIGNUP_POINTS,
+ },
+ "key_projects_enabled": {
+ "base_threshold": len(KEY_PROJECT_SLUGS),
+ "score_threshold": ENABLED_PROJECT_POINTS,
+ },
+ "completion": {
+ "base_threshold": 100,
+ "percent": True,
+ "score_threshold": COMPLETION_POINTS,
+ },
+ "chs": {"base_threshold": 100},
+}
+
+
+def get_monthly_snapshots(locales, date):
+ month_start = date.replace(day=1)
+ next_month_start = month_start + relativedelta(months=1)
+
+ snapshots = LocaleHealthSnapshot.objects.filter(
+ locale__in=locales,
+ created_at__gte=month_start,
+ created_at__lt=next_month_start,
+ ).order_by("locale_id", "-created_at")
+
+ latest = {}
+ for snapshot in snapshots:
+ latest.setdefault(snapshot.locale_id, snapshot)
+
+ return latest
+
+
+def get_monthly_snapshot_deltas(current_snapshots, previous_snapshots, metrics):
+ deltas = {}
+ for locale_id, curr_snapshot in current_snapshots.items():
+ prev_snapshot = previous_snapshots.get(locale_id)
+ locale_deltas = {}
+ for column_key in metrics:
+ if prev_snapshot is None:
+ locale_deltas[column_key] = None
+ continue
+ curr_value = getattr(curr_snapshot, column_key)
+ prev_value = getattr(prev_snapshot, column_key)
+ locale_deltas[column_key] = curr_value - prev_value
+ deltas[locale_id] = locale_deltas
+
+ return deltas
+
+
+@login_required(redirect_field_name="", login_url="/403")
+def insights_config(request):
+ """Configure which locales appear on the Insights dashboard."""
+ if not settings.ENABLE_INSIGHTS:
+ raise ImproperlyConfigured("ENABLE_INSIGHTS variable not set in settings.")
+
+ user = request.user
+ profile = user.profile
+
+ if not user.is_staff:
+ raise PermissionDenied
+
+ if request.method == "POST":
+ dashboard_locales_form = UserInsightsDashboardConfigForm(
+ request.POST, instance=profile
+ )
+
+ if dashboard_locales_form.is_valid():
+ dashboard_locales_form.save()
+ messages.success(request, "Configuration saved.")
+ return redirect("pontoon.insights")
+
+ dashboard_locales = profile.dashboard_locales
+
+ locales = Locale.objects.visible()
+ selected_locales = locales.filter(pk__in=dashboard_locales)
+ available_locales = locales.exclude(pk__in=dashboard_locales)
+ return render(
+ request,
+ "insights/config.html",
+ {
+ "available_locales": available_locales,
+ "selected_locales": selected_locales,
+ },
+ )
+
+
+@login_required(redirect_field_name="", login_url="/403")
def insights(request):
"""Insights page."""
+
if not settings.ENABLE_INSIGHTS:
raise ImproperlyConfigured("ENABLE_INSIGHTS variable not set in settings.")
+ user = request.user
+ profile = user.profile
+
+ if not user.is_staff:
+ raise PermissionDenied
+
+ dashboard_locales = profile.dashboard_locales
+ locales = Locale.objects.filter(pk__in=dashboard_locales).order_by("code")
+
+ current_anchor = timezone.now().date()
+ previous_anchor = current_anchor.replace(day=1) - relativedelta(days=1)
+ current_snapshots = get_monthly_snapshots(locales, current_anchor)
+ previous_snapshots = get_monthly_snapshots(locales, previous_anchor)
+ snapshot_base_deltas = get_monthly_snapshot_deltas(
+ current_snapshots, previous_snapshots, CHS_BASE_METRICS
+ )
+ snapshot_score_deltas = get_monthly_snapshot_deltas(
+ current_snapshots, previous_snapshots, CHS_SCORE_METRICS
+ )
+
# Cannot use cache.get_or_set(), because it always calls the slow function
# get_global_pretranslation_quality(). The reason we use cache in first place is to
# avoid that.
@@ -41,13 +213,21 @@ def insights(request):
project_pt_key, project_pretranslation_quality, settings.VIEW_CACHE_TIMEOUT
)
+ global_locale_health_insights = get_global_locale_health_insights(locales)
+
return render(
request,
"insights/insights.html",
{
"start_date": timezone.now() - relativedelta(years=1),
"end_date": timezone.now(),
+ "locales": locales,
+ "current_snapshots": current_snapshots,
+ "snapshot_base_deltas": snapshot_base_deltas,
+ "snapshot_score_deltas": snapshot_score_deltas,
+ "columns": CHS_COLUMNS,
"team_pretranslation_quality": team_pretranslation_quality,
"project_pretranslation_quality": project_pretranslation_quality,
+ "global_locale_health_insights": global_locale_health_insights,
},
)
diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py
index b3114234e7..97c169b84c 100644
--- a/pontoon/settings/base.py
+++ b/pontoon/settings/base.py
@@ -460,8 +460,13 @@ def _default_from_email():
},
"insights": {
"source_filenames": (
+ "css/heading_info.css",
+ "css/table.css",
+ "css/request.css",
+ "css/multiple_item_selector.css",
"css/insights_charts.css",
"css/insights.css",
+ "css/config.css",
),
"output_filename": "css/insights.min.css",
},
@@ -619,6 +624,8 @@ def _default_from_email():
"source_filenames": (
"js/lib/chart.umd.min.js",
"js/lib/chartjs-adapter-date-fns.bundle.min.js",
+ "js/table.js",
+ "js/multiple_item_selector.js",
"js/insights_charts.js",
"js/insights.js",
),
@@ -1212,6 +1219,11 @@ def account_username(user):
# email will be sent.
MONTHLY_ACTIVITY_SUMMARY_DAY = os.environ.get("MONTHLY_ACTIVITY_SUMMARY_DAY", 1)
+# Integer representing a day of the month on which the Community Health Score
+# snapshots are collected via collect_chs_snapshots().
+MONTHLY_CHS_SNAPSHOTS_DAY = os.environ.get("MONTHLY_CHS_SNAPSHOTS_DAY", 1)
+
+
# Number of days after user registration to send the 2nd onboarding email
ONBOARDING_EMAIL_2_DELAY = os.environ.get("ONBOARDING_EMAIL_2_DELAY", 2)
@@ -1255,6 +1267,35 @@ def account_username(user):
map(int, os.environ.get("BADGES_PROMOTION_THRESHOLDS", "1, 2, 5").split(","))
)
+# Used for Community Health Score calculations
+MANAGER_STRING_THRESHOLD = int(os.environ.get("MANAGER_STRING_THRESHOLD", 500))
+TRANSLATOR_STRING_THRESHOLD = int(os.environ.get("TRANSLATOR_STRING_THRESHOLD", 400))
+ACTIVE_CONTRIBUTOR_STRING_THRESHOLD = int(
+ os.environ.get("ACTIVE_CONTRIBUTOR_STRING_THRESHOLD", 200)
+)
+ALL_CONTRIBUTOR_STRING_THRESHOLD = int(
+ os.environ.get("ALL_CONTRIBUTOR_STRING_THRESHOLD", 200)
+)
+NEW_SIGNUP_STRING_THRESHOLD = int(os.environ.get("NEW_SIGNUP_STRING_THRESHOLD", 100))
+
+MANAGER_PEOPLE_THRESHOLD = int(os.environ.get("MANAGER_PEOPLE_THRESHOLD", 1))
+TRANSLATOR_PEOPLE_THRESHOLD = int(os.environ.get("TRANSLATOR_PEOPLE_THRESHOLD", 2))
+ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD = int(
+ os.environ.get("ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD", 2)
+)
+ALL_CONTRIBUTOR_PEOPLE_THRESHOLD = int(
+ os.environ.get("ALL_CONTRIBUTOR_PEOPLE_THRESHOLD", 2)
+)
+NEW_SIGNUP_PEOPLE_THRESHOLD = int(os.environ.get("NEW_SIGNUP_PEOPLE_THRESHOLD", 2))
+
+MANAGER_POINTS = float(os.environ.get("MANAGER_POINTS", 20.0))
+TRANSLATOR_POINTS = float(os.environ.get("TRANSLATOR_POINTS", 15.0))
+ACTIVE_CONTRIBUTOR_POINTS = float(os.environ.get("ACTIVE_CONTRIBUTOR_POINTS", 6.0))
+ALL_CONTRIBUTOR_POINTS = float(os.environ.get("ALL_CONTRIBUTOR_POINTS", 4.0))
+NEW_SIGNUP_POINTS = float(os.environ.get("NEW_SIGNUP_POINTS", 5.0))
+ENABLED_PROJECT_POINTS = float(os.environ.get("ENABLED_PROJECT_POINTS", 4.0))
+COMPLETION_POINTS = float(os.environ.get("COMPLETION_POINTS", 46.0))
+
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Used in the header of the Terminology (.TBX) files.
diff --git a/pontoon/teams/views.py b/pontoon/teams/views.py
index 0dd72831ac..141decc419 100644
--- a/pontoon/teams/views.py
+++ b/pontoon/teams/views.py
@@ -54,7 +54,7 @@
)
from pontoon.base.utils import require_AJAX
from pontoon.contributors.views import ContributorsMixin
-from pontoon.insights.utils import get_locale_insights
+from pontoon.insights.utils import get_locale_health_insights, get_locale_insights
from pontoon.teams.forms import LocaleRequestForm
from ..base.models.project import ProjectQuerySet
@@ -209,7 +209,9 @@ def ajax_insights(request, locale):
key = f"/{__name__}/{locale.code}/insights"
insights = cache.get(key)
if not insights:
- insights = get_locale_insights(Q(locale=locale))
+ locale_insights = get_locale_insights(Q(locale=locale))
+ locale_health_insights = get_locale_health_insights(locale)
+ insights = locale_insights | locale_health_insights
cache.set(key, insights, settings.VIEW_CACHE_TIMEOUT)
return render(request, "teams/includes/insights.html", insights)
diff --git a/pontoon/test/fixtures/base.py b/pontoon/test/fixtures/base.py
index 383f91657b..8146e30f52 100644
--- a/pontoon/test/fixtures/base.py
+++ b/pontoon/test/fixtures/base.py
@@ -11,6 +11,7 @@ def admin():
return factories.UserFactory.create(
username="admin",
is_superuser=True,
+ is_staff=True,
)
| Locale | +Managers | +Translators | ++ Contr. 1 + | ++ Contr. 2 + | +New | ++ Projects + | +Completion | +Score | +
+ {% if single %}
+
+ {{ delta_span(base_delta, percent) }}
+
+ {{ base_value }}{{ '%' if percent }}
+
+
+ {% else %}
+
+ {{ delta_span(base_delta, percent) }}
+
+ {{ base_value }}{{ '%' if percent }}
+
+
+
+ {{ delta_span(score_delta, false) }}
+
+ {{ score_value }}
+
+
+ {% endif %}
+ |
+{% endmacro %}
+
+{% macro item(locale, main_link, curr_snapshot=None, base_deltas=None, score_deltas=None, columns=None, class='limited') %}
+
|---|---|---|---|---|---|---|---|---|
| + + | + {% if curr_snapshot %} + {% for field, column in columns.items() %} + {% if field == 'chs' %} + {{ value_cell( + base_value=curr_snapshot[field], + base_delta=base_deltas[field] if base_deltas else None, + base_threshold=column.base_threshold, + single=True) }} + {% else %} + {{ value_cell( + base_value=curr_snapshot[field], + base_delta=base_deltas[field] if base_deltas else None, + base_threshold=column.base_threshold, + score_value=curr_snapshot[field + '_score'], + score_delta=score_deltas[field + '_score'] if score_deltas else None, + score_threshold=column.score_threshold, + percent=column.percent) }} + {% endif %} + {% endfor %} + {% else %} ++ No snapshot this month + | + {% endif %} +|||||||