From 7592cd624ff0f3299456e665ede90a2bab923326 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:33:35 -0400 Subject: [PATCH 01/34] add chs cron job (not fully functional) --- documentation/docs/dev/deployment.md | 12 + pontoon/insights/admin.py | 24 ++ pontoon/insights/chs.py | 292 ++++++++++++++++++ .../commands/collect_chs_snapshot.py | 24 ++ .../migrations/0021_localechssnapshot.py | 56 ++++ pontoon/insights/models.py | 17 + pontoon/insights/tasks.py | 30 ++ 7 files changed, 455 insertions(+) create mode 100644 pontoon/insights/chs.py create mode 100644 pontoon/insights/management/commands/collect_chs_snapshot.py create mode 100644 pontoon/insights/migrations/0021_localechssnapshot.py diff --git a/documentation/docs/dev/deployment.md b/documentation/docs/dev/deployment.md index 3a65cea071..6b0e184e20 100644 --- a/documentation/docs/dev/deployment.md +++ b/documentation/docs/dev/deployment.md @@ -494,6 +494,18 @@ the day, every day. ./manage.py collect_insights ``` +### Collect CHS Snapshot + +Captures per-locale Contributor Health Score metrics - completion, key-project +enablement, active managers / translators / contributors & new signups into +`LocaleChsSnapshot` model. Used by the CHS dashboard for month-over-month +comparisons and by the Insights pages for monthly trend charts. The job is +designed to run once a month on the first of each month. + +``` bash +./manage.py collect_chs_snapshot +``` + ### Warm up cache We cache data for some of the views (e.g. Contributors) for a day. Some diff --git a/pontoon/insights/admin.py b/pontoon/insights/admin.py index fce81ecfb0..9039300cc4 100644 --- a/pontoon/insights/admin.py +++ b/pontoon/insights/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from pontoon.insights.models import ( + LocaleChsSnapshot, LocaleInsightsSnapshot, ProjectLocaleInsightsSnapshot, ) @@ -39,5 +40,28 @@ class ProjectLocaleInsightsSnapshotAdmin(admin.ModelAdmin): readonly_fields = ("project_locale",) +class LocaleChsSnapshotAdmin(admin.ModelAdmin): + search_fields = [ + "pk", + "locale__code", + "locale__name", + ] + list_display = ( + "pk", + "locale", + "created_at", + "completion", + "key_projects_enabled", + "active_managers", + "active_translators", + "active_contributors", + "active_contributors_200_approved", + "new_signups", + "chs_score", + ) + list_filter = ("created_at",) + + admin.site.register(LocaleInsightsSnapshot, LocaleInsightsSnapshotAdmin) admin.site.register(ProjectLocaleInsightsSnapshot, ProjectLocaleInsightsSnapshotAdmin) +admin.site.register(LocaleChsSnapshot, LocaleChsSnapshotAdmin) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py new file mode 100644 index 0000000000..9f3c51a111 --- /dev/null +++ b/pontoon/insights/chs.py @@ -0,0 +1,292 @@ +from collections import defaultdict +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from django.db.models import Count, F, Q, Sum + +from pontoon.actionlog.models import ActionLog +from pontoon.base.models import Locale, TranslatedResource, Translation +from pontoon.base.models.project_locale import ProjectLocale +from pontoon.insights.models import LocaleChsSnapshot + + +KEY_PROJECT_SLUGS = [ + "firefox-for-android", + "firefox-for-ios", + "firefox-monitor-website", + "firefox-relay-website", + "firefox", + "mozilla-accounts", + "mozilla-vpn-client", +] + +# Contribution Data +MANAGER_STRING_THRESHOLD = 500 +TRANSLATOR_STRING_THRESHOLD = 400 +CONTRIBUTOR_THRESHOLD = 200 +CONTRIBUTOR_APPROVED_THRESHOLD = 200 +NEW_SIGNUP_SUBMISSION_THRESHOLD = 100 + +# CHS score calculation +MANAGER_PEOPLE_THRESHOLD = 1 +TRANSLATOR_PEOPLE_THRESHOLD = 2 +CONTRIBUTOR_PEOPLE_THRESHOLD = 2 +CONTRIBUTOR_200_APPROVED_PEOPLE_THRESHOLD = 2 +NEW_SIGNUP_PEOPLE_THRESHOLD = 2 + +MANAGER_POINTS = 20.0 +TRANSLATOR_POINTS = 15.0 +CONTRIBUTOR_POINTS = 4.0 +CONTRIBUTOR_200_APPROVED_POINTS = 6.0 +NEW_SIGNUP_POINTS = 5.0 +ENABLED_PROJECT_POINTS = 4.0 +COMPLETION_POINTS = 46.0 + + +def get_completion_by_locale(locales) -> dict[int, float]: + """Locale-level completion %: (approved + warnings) / total * 100.""" + + locale_groupings = ( + TranslatedResource.objects.filter( + locale__in=locales, + resource__project__disabled=False, + resource__project__system_project=False, + resource__project__visibility="public", + ) + .values("locale") + .annotate( + total=Sum("total_strings", default=0), + approved=Sum("approved_strings", default=0), + warnings=Sum("strings_with_warnings", default=0), + ) + ) + + locale_completion = { + l_grouping["locale"]: round( + 100 + * (l_grouping["approved"] + l_grouping["warnings"]) + / l_grouping["total"], + 2, + ) + if l_grouping["total"] > 0 + else 0.0 + for l_grouping in locale_groupings + } + + return locale_completion + + +def get_key_projects_enabled_by_locale( + locales, key_project_slugs: list[str] +) -> dict[int, int]: + """Count of active key projects enabled for each locale.""" + pl_counts = ( + ProjectLocale.objects.filter( + locale__in=locales, + project__slug__in=key_project_slugs, + project__disabled=False, + ) + .values("locale_id") + .annotate(count=Count("id")) + ) + return {pl_count["locale_id"]: pl_count["count"] for pl_count in pl_counts} + + +def get_contributor_metrics_by_locale(locales, end_date: datetime) -> dict[int, dict]: + """ + Per-locale active-contributor counts over the 12-month window ending at end_dt. + """ + start_date = end_date - relativedelta(months=13) + (print("start date", start_date),) + print("end date", end_date) + + managers = defaultdict(set) + translators = defaultdict(set) + for row in locales.values( + "pk", + manager=F("managers_group__user"), + translator=F("translators_group__user"), + ): + if row["manager"] is not None: + managers[row["pk"]].add(row["manager"]) + if row["translator"] is not None: + translators[row["pk"]].add(row["translator"]) + + action_counts = { + (row["performed_by"], row["locale_pk"]): row["action_count"] + for row in ( + ActionLog.objects.filter( + created_at__gte=start_date, + created_at__lte=end_date, + action_type__in=[ + ActionLog.ActionType.TRANSLATION_APPROVED, + ActionLog.ActionType.TRANSLATION_REJECTED, + ], + translation__locale__in=locales, + performed_by__profile__system_user=False, + ) + .values("performed_by", locale_pk=F("translation__locale")) + .annotate(action_count=Count("id")) + ) + } + + contributor_translations = ( + Translation.objects.filter( + locale__in=locales, + user__isnull=False, + user__profile__system_user=False, + date__gte=start_date, + date__lte=end_date, + ) + .values( + "locale_id", + "user_id", + joined=F("user__date_joined"), + is_superuser=F("user__is_superuser"), + ) + .annotate( + total_count=Count("id"), + approved_count=Count("id", filter=Q(approved=True)), + ) + ) + + locale_contributors = { + locale.pk: { + "active_managers": 0, + "active_translators": 0, + "active_contributors": 0, + "active_contributors_200_approved": 0, + "new_signups": 0, + } + for locale in locales + } + + for row in contributor_translations: + locale_id = row["locale_id"] + user_id = row["user_id"] + joined = row["joined"] + is_superuser = row["is_superuser"] + total = row["total_count"] + approved = row["approved_count"] + + action_count = action_counts.get((user_id, locale_id), 0) + + if locale_id == 185: + print("user_id", user_id) + print("total", total) + print("approved", approved) + + if not total: + continue + + if user_id in managers[locale_id]: + if action_count + approved > MANAGER_STRING_THRESHOLD: + locale_contributors[locale_id]["active_managers"] += 1 + elif user_id in translators[locale_id]: + if action_count + approved > TRANSLATOR_STRING_THRESHOLD: + locale_contributors[locale_id]["active_translators"] += 1 + else: + if is_superuser: + continue + if total > CONTRIBUTOR_THRESHOLD: + locale_contributors[locale_id]["active_contributors"] += 1 + if approved > CONTRIBUTOR_APPROVED_THRESHOLD: + locale_contributors[locale_id]["active_contributors_200_approved"] += 1 + if approved > NEW_SIGNUP_SUBMISSION_THRESHOLD and joined >= start_date: + locale_contributors[locale_id]["new_signups"] += 1 + + return locale_contributors + + +def compute_chs(args: dict) -> float: + active_managers = args.get("active_managers", 0) + active_translators = args.get("active_translators", 0) + active_contributors = args.get("active_contributors", 0) + active_contributors_200_approved = args.get("active_contributors_200_approved", 0) + new_signups = args.get("new_signups", 0) + key_projects_enabled = args.get("key_projects_enabled", 0) + completion = args.get("completion", 0.0) + + total_manager_points = MANAGER_POINTS if active_managers >= 1 else 0 + + if active_translators >= 2: + total_translator_points = TRANSLATOR_POINTS + elif active_translators >= 1: + total_translator_points = TRANSLATOR_POINTS / 2 + else: + total_translator_points = 0 + + if active_contributors >= 2: + total_contributor_points = CONTRIBUTOR_POINTS + elif active_contributors >= 1: + total_contributor_points = CONTRIBUTOR_POINTS / 2 + else: + total_contributor_points = 0 + + if active_contributors_200_approved >= 2: + total_contributor_200_approved_points = CONTRIBUTOR_200_APPROVED_POINTS + elif active_contributors_200_approved >= 1: + total_contributor_200_approved_points = CONTRIBUTOR_200_APPROVED_POINTS / 2 + else: + total_contributor_200_approved_points = 0 + + if new_signups >= 2: + total_new_signup_points = NEW_SIGNUP_POINTS + elif new_signups >= 1: + total_new_signup_points = NEW_SIGNUP_POINTS / 2 + else: + total_new_signup_points = 0 + + total_enabled_project_points = ( + key_projects_enabled / len(KEY_PROJECT_SLUGS) + ) * ENABLED_PROJECT_POINTS + total_completion_points = round((completion / 100) * COMPLETION_POINTS, 1) + + chs_score = ( + total_manager_points + + total_translator_points + + total_contributor_points + + total_contributor_200_approved_points + + total_new_signup_points + + total_enabled_project_points + + total_completion_points + ) + + return round(chs_score, 2) + + +def build_chs_snapshots(end_date: datetime) -> list[LocaleChsSnapshot]: + """Assemble one LocaleChsSnapshot per available locale for dt_max.""" + locales = Locale.objects.visible() + + completion = get_completion_by_locale(locales) + enabled = get_key_projects_enabled_by_locale(locales, KEY_PROJECT_SLUGS) + contributors = get_contributor_metrics_by_locale(locales, end_date) + + snapshots = [] + for locale in locales: + c = contributors.get(locale.pk, {}) + args = { + "completion": completion.get(locale.pk, 0.0), + "key_projects_enabled": enabled.get(locale.pk, 0), + "active_managers": c.get("active_managers", 0), + "active_translators": c.get("active_translators", 0), + "active_contributors": c.get("active_contributors", 0), + "active_contributors_200_approved": c.get( + "active_contributors_200_approved", 0 + ), + "new_signups": c.get("new_signups", 0), + } + chs_score = compute_chs(args) + + snapshots.append( + LocaleChsSnapshot( + locale=locale, + created_at=end_date, + **args, + chs_score=chs_score, + ) + ) + + return snapshots diff --git a/pontoon/insights/management/commands/collect_chs_snapshot.py b/pontoon/insights/management/commands/collect_chs_snapshot.py new file mode 100644 index 0000000000..d26fdb6854 --- /dev/null +++ b/pontoon/insights/management/commands/collect_chs_snapshot.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from pontoon.insights.tasks import collect_chs_snapshot + + +class Command(BaseCommand): + help = "Collect monthly CHS snapshot, one row per locale." + + def handle(self, *args, **options): + """ + Per-locale Contributor Health Score (CHS) metrics — completion, key-project + enablement, active managers / translators / contributors, new signups — + snapshotted once per month. The dashboard reads two snapshots (current month + and the previous one) to render month-over-month deltas; the Insights tab + reads 12 snapshots for the trend chart. + + Designed to run on the 1st of every month. + """ + collect_chs_snapshot( + end_date=timezone.make_aware(datetime(year=2026, month=5, day=22)) + ) diff --git a/pontoon/insights/migrations/0021_localechssnapshot.py b/pontoon/insights/migrations/0021_localechssnapshot.py new file mode 100644 index 0000000000..7ee0be28f2 --- /dev/null +++ b/pontoon/insights/migrations/0021_localechssnapshot.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.14 on 2026-06-02 19:59 + +import django.db.models.deletion + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("base", "0113_userbanlog"), + ("insights", "0020_fix_pretranslations_chrf_score"), + ] + + operations = [ + migrations.CreateModel( + name="LocaleChsSnapshot", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateField()), + ("completion", models.FloatField(default=0.0)), + ("key_projects_enabled", models.PositiveIntegerField(default=0)), + ("active_managers", models.PositiveIntegerField(default=0)), + ("active_translators", models.PositiveIntegerField(default=0)), + ("active_contributors", models.PositiveIntegerField(default=0)), + ( + "active_contributors_200_approved", + models.PositiveIntegerField(default=0), + ), + ("new_signups", models.PositiveIntegerField(default=0)), + ("chs_score", models.FloatField(default=0.0)), + ( + "locale", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="base.locale" + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["created_at", "locale"], + name="insights_lo_created_eb85fa_idx", + ) + ], + "unique_together": {("locale", "created_at")}, + }, + ), + ] diff --git a/pontoon/insights/models.py b/pontoon/insights/models.py index e788c9e956..2276da1b76 100644 --- a/pontoon/insights/models.py +++ b/pontoon/insights/models.py @@ -83,3 +83,20 @@ class LocaleInsightsSnapshot(InsightsSnapshot): class ProjectLocaleInsightsSnapshot(InsightsSnapshot): project_locale = models.ForeignKey("base.ProjectLocale", models.CASCADE) + + +class LocaleChsSnapshot(models.Model): + locale = models.ForeignKey("base.Locale", on_delete=models.CASCADE) + created_at = models.DateField() + completion = models.FloatField(default=0.0) + key_projects_enabled = models.PositiveIntegerField(default=0) + active_managers = models.PositiveIntegerField(default=0) + active_translators = models.PositiveIntegerField(default=0) + active_contributors = models.PositiveIntegerField(default=0) + active_contributors_200_approved = models.PositiveIntegerField(default=0) + new_signups = models.PositiveIntegerField(default=0) + chs_score = models.FloatField(default=0.0) + + class Meta: + unique_together = [("locale", "created_at")] + indexes = [models.Index(fields=["created_at", "locale"])] diff --git a/pontoon/insights/tasks.py b/pontoon/insights/tasks.py index dc52900b40..ecdfd45d98 100644 --- a/pontoon/insights/tasks.py +++ b/pontoon/insights/tasks.py @@ -18,7 +18,9 @@ from pontoon.actionlog.models import ActionLog from pontoon.base.models import Entity, Locale, TranslatedResource, Translation +from pontoon.insights.chs import build_chs_snapshots from pontoon.insights.models import ( + LocaleChsSnapshot, LocaleInsightsSnapshot, ProjectLocaleInsightsSnapshot, ) @@ -615,3 +617,31 @@ def get_active_users( "reviewers": len(active_reviewers & locale_reviewers), "contributors": len(active_contributors & locale_contributors), } + + +@shared_task +def collect_chs_snapshot(end_date: datetime | None = None): + """Collect a monthly CHS snapshot, one row per available locale.""" + + if end_date is None: + end_date = timezone.now().replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + + # args = { + # "completion": 98.11, + # "key_projects_enabled": 6, + # "active_managers": 1, + # "active_translators": 0, + # "active_contributors": 1, + # "active_contributors_200_approved": 0, + # "new_signups": 2, + # } + + # print("chs", compute_chs(args)) + + snapshots = build_chs_snapshots(end_date) + LocaleChsSnapshot.objects.bulk_create(snapshots, ignore_conflicts=True) + log.info( + f"Collected CHS snapshot for {end_date.date()}: {len(snapshots)} snapshots queued." + ) From 474b32df7770d637b677c2d04bfe2009d4efd60c Mon Sep 17 00:00:00 2001 From: Jamie <164675620+functionzz@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:49:48 -0400 Subject: [PATCH 02/34] Update pontoon/insights/chs.py Co-authored-by: Francesco Lodolo --- pontoon/insights/chs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index 9f3c51a111..3ba194c527 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -14,7 +14,7 @@ KEY_PROJECT_SLUGS = [ "firefox-for-android", "firefox-for-ios", - "firefox-monitor-website", + "mozilla-monitor-website", "firefox-relay-website", "firefox", "mozilla-accounts", From 39ed6ccc8884faebe352f8b84a1d18821cd6dcc9 Mon Sep 17 00:00:00 2001 From: Jamie <164675620+functionzz@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:36:51 -0400 Subject: [PATCH 03/34] Update pontoon/insights/chs.py Co-authored-by: Francesco Lodolo --- pontoon/insights/chs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index 3ba194c527..3034660211 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -24,7 +24,7 @@ # Contribution Data MANAGER_STRING_THRESHOLD = 500 TRANSLATOR_STRING_THRESHOLD = 400 -CONTRIBUTOR_THRESHOLD = 200 +CONTRIBUTOR_SUBMITTED_THRESHOLD = 200 CONTRIBUTOR_APPROVED_THRESHOLD = 200 NEW_SIGNUP_SUBMISSION_THRESHOLD = 100 From db3fd411aaf024bb0623ed2c31857959f1fc3c6a Mon Sep 17 00:00:00 2001 From: Jamie <164675620+functionzz@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:37:00 -0400 Subject: [PATCH 04/34] Update pontoon/insights/chs.py Co-authored-by: Francesco Lodolo --- pontoon/insights/chs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index 3034660211..50af83094d 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -26,7 +26,7 @@ TRANSLATOR_STRING_THRESHOLD = 400 CONTRIBUTOR_SUBMITTED_THRESHOLD = 200 CONTRIBUTOR_APPROVED_THRESHOLD = 200 -NEW_SIGNUP_SUBMISSION_THRESHOLD = 100 +NEW_SIGNUP_SUBMITTED_THRESHOLD = 100 # CHS score calculation MANAGER_PEOPLE_THRESHOLD = 1 From a7f24ff2b46371eef7ad809d26e16f283298dc30 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:51:01 -0400 Subject: [PATCH 05/34] add env variables over hardcoded constants --- pontoon/insights/chs.py | 50 +++++++++++++++++----------------------- pontoon/settings/base.py | 29 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index 50af83094d..7fecb5cd46 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -9,6 +9,20 @@ from pontoon.base.models import Locale, TranslatedResource, Translation from pontoon.base.models.project_locale import ProjectLocale from pontoon.insights.models import LocaleChsSnapshot +from pontoon.settings.base import ( + ACTIVE_CONTRIBUTOR_POINTS, + ACTIVE_CONTRIBUTOR_STRING_THRESHOLD, + ALL_CONTRIBUTOR_POINTS, + ALL_CONTRIBUTOR_STRING_THRESHOLD, + COMPLETION_POINTS, + ENABLED_PROJECT_POINTS, + MANAGER_POINTS, + MANAGER_STRING_THRESHOLD, + NEW_SIGNUP_POINTS, + NEW_SIGNUP_STRING_THRESHOLD, + TRANSLATOR_POINTS, + TRANSLATOR_STRING_THRESHOLD, +) KEY_PROJECT_SLUGS = [ @@ -21,28 +35,6 @@ "mozilla-vpn-client", ] -# Contribution Data -MANAGER_STRING_THRESHOLD = 500 -TRANSLATOR_STRING_THRESHOLD = 400 -CONTRIBUTOR_SUBMITTED_THRESHOLD = 200 -CONTRIBUTOR_APPROVED_THRESHOLD = 200 -NEW_SIGNUP_SUBMITTED_THRESHOLD = 100 - -# CHS score calculation -MANAGER_PEOPLE_THRESHOLD = 1 -TRANSLATOR_PEOPLE_THRESHOLD = 2 -CONTRIBUTOR_PEOPLE_THRESHOLD = 2 -CONTRIBUTOR_200_APPROVED_PEOPLE_THRESHOLD = 2 -NEW_SIGNUP_PEOPLE_THRESHOLD = 2 - -MANAGER_POINTS = 20.0 -TRANSLATOR_POINTS = 15.0 -CONTRIBUTOR_POINTS = 4.0 -CONTRIBUTOR_200_APPROVED_POINTS = 6.0 -NEW_SIGNUP_POINTS = 5.0 -ENABLED_PROJECT_POINTS = 4.0 -COMPLETION_POINTS = 46.0 - def get_completion_by_locale(locales) -> dict[int, float]: """Locale-level completion %: (approved + warnings) / total * 100.""" @@ -189,11 +181,11 @@ def get_contributor_metrics_by_locale(locales, end_date: datetime) -> dict[int, else: if is_superuser: continue - if total > CONTRIBUTOR_THRESHOLD: + if total > ALL_CONTRIBUTOR_STRING_THRESHOLD: locale_contributors[locale_id]["active_contributors"] += 1 - if approved > CONTRIBUTOR_APPROVED_THRESHOLD: + if approved > ACTIVE_CONTRIBUTOR_STRING_THRESHOLD: locale_contributors[locale_id]["active_contributors_200_approved"] += 1 - if approved > NEW_SIGNUP_SUBMISSION_THRESHOLD and joined >= start_date: + if approved > NEW_SIGNUP_STRING_THRESHOLD and joined >= start_date: locale_contributors[locale_id]["new_signups"] += 1 return locale_contributors @@ -218,16 +210,16 @@ def compute_chs(args: dict) -> float: total_translator_points = 0 if active_contributors >= 2: - total_contributor_points = CONTRIBUTOR_POINTS + total_contributor_points = ALL_CONTRIBUTOR_POINTS elif active_contributors >= 1: - total_contributor_points = CONTRIBUTOR_POINTS / 2 + total_contributor_points = ALL_CONTRIBUTOR_POINTS / 2 else: total_contributor_points = 0 if active_contributors_200_approved >= 2: - total_contributor_200_approved_points = CONTRIBUTOR_200_APPROVED_POINTS + total_contributor_200_approved_points = ACTIVE_CONTRIBUTOR_POINTS elif active_contributors_200_approved >= 1: - total_contributor_200_approved_points = CONTRIBUTOR_200_APPROVED_POINTS / 2 + total_contributor_200_approved_points = ACTIVE_CONTRIBUTOR_POINTS / 2 else: total_contributor_200_approved_points = 0 diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index b3114234e7..ebd239d16b 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -1255,6 +1255,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. From 257014a86e5b2ca8223e85812e24f46388d51932 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:01:53 -0400 Subject: [PATCH 06/34] change LocaleChsSnapshot to LocaleHealthSnapshot, completion formula tweak, rename active_contributors+active_contributors_200_approved, migrations --- documentation/docs/dev/deployment.md | 2 +- pontoon/insights/admin.py | 17 ++-- pontoon/insights/chs.py | 80 ++++++++++++------- ...tive_contributors_200_approved_and_more.py | 21 +++++ ...pshot_delete_localechssnapshot_and_more.py | 67 ++++++++++++++++ ...score_localehealthsnapshot_chs_and_more.py | 47 +++++++++++ pontoon/insights/models.py | 13 ++- pontoon/insights/tasks.py | 4 +- 8 files changed, 210 insertions(+), 41 deletions(-) create mode 100644 pontoon/insights/migrations/0022_remove_localechssnapshot_active_contributors_200_approved_and_more.py create mode 100644 pontoon/insights/migrations/0023_localehealthsnapshot_delete_localechssnapshot_and_more.py create mode 100644 pontoon/insights/migrations/0024_rename_chs_score_localehealthsnapshot_chs_and_more.py diff --git a/documentation/docs/dev/deployment.md b/documentation/docs/dev/deployment.md index 6b0e184e20..ac9642d97b 100644 --- a/documentation/docs/dev/deployment.md +++ b/documentation/docs/dev/deployment.md @@ -498,7 +498,7 @@ the day, every day. Captures per-locale Contributor Health Score metrics - completion, key-project enablement, active managers / translators / contributors & new signups into -`LocaleChsSnapshot` model. Used by the CHS dashboard for month-over-month +`LocaleHealthSnapshot` model. Used by the CHS dashboard for month-over-month comparisons and by the Insights pages for monthly trend charts. The job is designed to run once a month on the first of each month. diff --git a/pontoon/insights/admin.py b/pontoon/insights/admin.py index 9039300cc4..25f65c8dfd 100644 --- a/pontoon/insights/admin.py +++ b/pontoon/insights/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from pontoon.insights.models import ( - LocaleChsSnapshot, + LocaleHealthSnapshot, LocaleInsightsSnapshot, ProjectLocaleInsightsSnapshot, ) @@ -40,7 +40,7 @@ class ProjectLocaleInsightsSnapshotAdmin(admin.ModelAdmin): readonly_fields = ("project_locale",) -class LocaleChsSnapshotAdmin(admin.ModelAdmin): +class LocaleHealthSnapshotAdmin(admin.ModelAdmin): search_fields = [ "pk", "locale__code", @@ -55,13 +55,20 @@ class LocaleChsSnapshotAdmin(admin.ModelAdmin): "active_managers", "active_translators", "active_contributors", - "active_contributors_200_approved", + "all_contributors", "new_signups", - "chs_score", + "completion_score", + "key_projects_enabled_score", + "active_managers_score", + "active_translators_score", + "active_contributors_score", + "all_contributors_score", + "new_signups_score", + "chs", ) list_filter = ("created_at",) admin.site.register(LocaleInsightsSnapshot, LocaleInsightsSnapshotAdmin) admin.site.register(ProjectLocaleInsightsSnapshot, ProjectLocaleInsightsSnapshotAdmin) -admin.site.register(LocaleChsSnapshot, LocaleChsSnapshotAdmin) +admin.site.register(LocaleHealthSnapshot, LocaleHealthSnapshotAdmin) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index 7fecb5cd46..a870a3e8a3 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -7,8 +7,9 @@ from pontoon.actionlog.models import ActionLog from pontoon.base.models import Locale, TranslatedResource, Translation +from pontoon.base.models.project import Project from pontoon.base.models.project_locale import ProjectLocale -from pontoon.insights.models import LocaleChsSnapshot +from pontoon.insights.models import LocaleHealthSnapshot from pontoon.settings.base import ( ACTIVE_CONTRIBUTOR_POINTS, ACTIVE_CONTRIBUTOR_STRING_THRESHOLD, @@ -39,9 +40,12 @@ def get_completion_by_locale(locales) -> dict[int, float]: """Locale-level completion %: (approved + warnings) / total * 100.""" + projects = Project.objects.filter(slug__in=KEY_PROJECT_SLUGS) + locale_groupings = ( TranslatedResource.objects.filter( locale__in=locales, + resource__project__in=projects, resource__project__disabled=False, resource__project__system_project=False, resource__project__visibility="public", @@ -148,7 +152,7 @@ def get_contributor_metrics_by_locale(locales, end_date: datetime) -> dict[int, "active_managers": 0, "active_translators": 0, "active_contributors": 0, - "active_contributors_200_approved": 0, + "all_contributors": 0, "new_signups": 0, } for locale in locales @@ -181,10 +185,10 @@ def get_contributor_metrics_by_locale(locales, end_date: datetime) -> dict[int, else: if is_superuser: continue - if total > ALL_CONTRIBUTOR_STRING_THRESHOLD: - locale_contributors[locale_id]["active_contributors"] += 1 if approved > ACTIVE_CONTRIBUTOR_STRING_THRESHOLD: - locale_contributors[locale_id]["active_contributors_200_approved"] += 1 + locale_contributors[locale_id]["active_contributors"] += 1 + if total > ALL_CONTRIBUTOR_STRING_THRESHOLD: + locale_contributors[locale_id]["all_contributors"] += 1 if approved > NEW_SIGNUP_STRING_THRESHOLD and joined >= start_date: locale_contributors[locale_id]["new_signups"] += 1 @@ -195,7 +199,7 @@ def compute_chs(args: dict) -> float: active_managers = args.get("active_managers", 0) active_translators = args.get("active_translators", 0) active_contributors = args.get("active_contributors", 0) - active_contributors_200_approved = args.get("active_contributors_200_approved", 0) + all_contributors = args.get("all_contributors", 0) new_signups = args.get("new_signups", 0) key_projects_enabled = args.get("key_projects_enabled", 0) completion = args.get("completion", 0.0) @@ -210,18 +214,18 @@ def compute_chs(args: dict) -> float: total_translator_points = 0 if active_contributors >= 2: - total_contributor_points = ALL_CONTRIBUTOR_POINTS + total_active_contributor_points = ACTIVE_CONTRIBUTOR_POINTS elif active_contributors >= 1: - total_contributor_points = ALL_CONTRIBUTOR_POINTS / 2 + total_active_contributor_points = ACTIVE_CONTRIBUTOR_POINTS / 2 else: - total_contributor_points = 0 + total_active_contributor_points = 0 - if active_contributors_200_approved >= 2: - total_contributor_200_approved_points = ACTIVE_CONTRIBUTOR_POINTS - elif active_contributors_200_approved >= 1: - total_contributor_200_approved_points = ACTIVE_CONTRIBUTOR_POINTS / 2 + if all_contributors >= 2: + total_all_contributor_points = ALL_CONTRIBUTOR_POINTS + elif all_contributors >= 1: + total_all_contributor_points = ALL_CONTRIBUTOR_POINTS / 2 else: - total_contributor_200_approved_points = 0 + total_all_contributor_points = 0 if new_signups >= 2: total_new_signup_points = NEW_SIGNUP_POINTS @@ -235,21 +239,42 @@ def compute_chs(args: dict) -> float: ) * ENABLED_PROJECT_POINTS total_completion_points = round((completion / 100) * COMPLETION_POINTS, 1) - chs_score = ( + chs = round( total_manager_points + total_translator_points - + total_contributor_points - + total_contributor_200_approved_points + + total_active_contributor_points + + total_all_contributor_points + total_new_signup_points + total_enabled_project_points - + total_completion_points + + total_completion_points, + 2, ) - return round(chs_score, 2) + print("total manager points:", total_manager_points) + print("total translator points:", total_translator_points) + print("total active contributor points:", total_active_contributor_points) + print("total all contributor points:", total_all_contributor_points) + print("total new signup points:", total_new_signup_points) + print("total enabled project points:", total_enabled_project_points) + print("total completion points:", total_completion_points) + print("CHS score:", chs) + + chs_fields = { + "completion_score": total_completion_points, + "key_projects_enabled_score": total_enabled_project_points, + "active_managers_score": total_manager_points, + "active_translators_score": total_translator_points, + "active_contributors_score": total_active_contributor_points, + "all_contributors_score": total_all_contributor_points, + "new_signups_score": total_new_signup_points, + "chs": chs, + } + + return chs_fields -def build_chs_snapshots(end_date: datetime) -> list[LocaleChsSnapshot]: - """Assemble one LocaleChsSnapshot per available locale for dt_max.""" +def build_chs_snapshots(end_date: datetime) -> list[LocaleHealthSnapshot]: + """Assemble one LocaleHealthSnapshot per available locale for dt_max.""" locales = Locale.objects.visible() completion = get_completion_by_locale(locales) @@ -265,19 +290,14 @@ def build_chs_snapshots(end_date: datetime) -> list[LocaleChsSnapshot]: "active_managers": c.get("active_managers", 0), "active_translators": c.get("active_translators", 0), "active_contributors": c.get("active_contributors", 0), - "active_contributors_200_approved": c.get( - "active_contributors_200_approved", 0 - ), + "all_contributors": c.get("all_contributors", 0), "new_signups": c.get("new_signups", 0), } - chs_score = compute_chs(args) + chs_fields = compute_chs(args) snapshots.append( - LocaleChsSnapshot( - locale=locale, - created_at=end_date, - **args, - chs_score=chs_score, + LocaleHealthSnapshot( + locale=locale, created_at=end_date, **args, **chs_fields ) ) diff --git a/pontoon/insights/migrations/0022_remove_localechssnapshot_active_contributors_200_approved_and_more.py b/pontoon/insights/migrations/0022_remove_localechssnapshot_active_contributors_200_approved_and_more.py new file mode 100644 index 0000000000..f58d784203 --- /dev/null +++ b/pontoon/insights/migrations/0022_remove_localechssnapshot_active_contributors_200_approved_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.14 on 2026-06-09 17:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("insights", "0021_localechssnapshot"), + ] + + operations = [ + migrations.RemoveField( + model_name="localechssnapshot", + name="active_contributors_200_approved", + ), + migrations.AddField( + model_name="localechssnapshot", + name="all_contributors", + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/pontoon/insights/migrations/0023_localehealthsnapshot_delete_localechssnapshot_and_more.py b/pontoon/insights/migrations/0023_localehealthsnapshot_delete_localechssnapshot_and_more.py new file mode 100644 index 0000000000..d53ec154f1 --- /dev/null +++ b/pontoon/insights/migrations/0023_localehealthsnapshot_delete_localechssnapshot_and_more.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.14 on 2026-06-09 19:14 + +import django.db.models.deletion + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("base", "0113_userbanlog"), + ( + "insights", + "0022_remove_localechssnapshot_active_contributors_200_approved_and_more", + ), + ] + + operations = [ + migrations.CreateModel( + name="LocaleHealthSnapshot", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateField()), + ("completion", models.FloatField(default=0.0)), + ("key_projects_enabled", models.PositiveIntegerField(default=0)), + ("active_managers", models.PositiveIntegerField(default=0)), + ("active_translators", models.PositiveIntegerField(default=0)), + ("active_contributors", models.PositiveIntegerField(default=0)), + ("all_contributors", models.PositiveIntegerField(default=0)), + ("new_signups", models.PositiveIntegerField(default=0)), + ("completion_score", models.FloatField(default=0.0)), + ("key_projects_enabled_score", models.PositiveIntegerField(default=0)), + ("active_managers_score", models.PositiveIntegerField(default=0)), + ("active_translators_score", models.PositiveIntegerField(default=0)), + ("active_contributors_score", models.PositiveIntegerField(default=0)), + ("all_contributors_score", models.PositiveIntegerField(default=0)), + ("new_signups_score", models.PositiveIntegerField(default=0)), + ("chs_score", models.FloatField(default=0.0)), + ( + "locale", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="base.locale" + ), + ), + ], + ), + migrations.DeleteModel( + name="LocaleChsSnapshot", + ), + migrations.AddIndex( + model_name="localehealthsnapshot", + index=models.Index( + fields=["created_at", "locale"], name="insights_lo_created_b801b7_idx" + ), + ), + migrations.AlterUniqueTogether( + name="localehealthsnapshot", + unique_together={("locale", "created_at")}, + ), + ] diff --git a/pontoon/insights/migrations/0024_rename_chs_score_localehealthsnapshot_chs_and_more.py b/pontoon/insights/migrations/0024_rename_chs_score_localehealthsnapshot_chs_and_more.py new file mode 100644 index 0000000000..3fac542220 --- /dev/null +++ b/pontoon/insights/migrations/0024_rename_chs_score_localehealthsnapshot_chs_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.14 on 2026-06-09 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("insights", "0023_localehealthsnapshot_delete_localechssnapshot_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="localehealthsnapshot", + old_name="chs_score", + new_name="chs", + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="active_contributors_score", + field=models.FloatField(default=0), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="active_managers_score", + field=models.FloatField(default=0), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="active_translators_score", + field=models.FloatField(default=0), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="all_contributors_score", + field=models.FloatField(default=0), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="key_projects_enabled_score", + field=models.FloatField(default=0), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="new_signups_score", + field=models.FloatField(default=0), + ), + ] diff --git a/pontoon/insights/models.py b/pontoon/insights/models.py index 2276da1b76..50616e2c86 100644 --- a/pontoon/insights/models.py +++ b/pontoon/insights/models.py @@ -85,7 +85,7 @@ class ProjectLocaleInsightsSnapshot(InsightsSnapshot): project_locale = models.ForeignKey("base.ProjectLocale", models.CASCADE) -class LocaleChsSnapshot(models.Model): +class LocaleHealthSnapshot(models.Model): locale = models.ForeignKey("base.Locale", on_delete=models.CASCADE) created_at = models.DateField() completion = models.FloatField(default=0.0) @@ -93,9 +93,16 @@ class LocaleChsSnapshot(models.Model): active_managers = models.PositiveIntegerField(default=0) active_translators = models.PositiveIntegerField(default=0) active_contributors = models.PositiveIntegerField(default=0) - active_contributors_200_approved = models.PositiveIntegerField(default=0) + all_contributors = models.PositiveIntegerField(default=0) new_signups = models.PositiveIntegerField(default=0) - chs_score = models.FloatField(default=0.0) + completion_score = models.FloatField(default=0.0) + key_projects_enabled_score = models.FloatField(default=0) + active_managers_score = models.FloatField(default=0) + active_translators_score = models.FloatField(default=0) + active_contributors_score = models.FloatField(default=0) + all_contributors_score = models.FloatField(default=0) + new_signups_score = models.FloatField(default=0) + chs = models.FloatField(default=0.0) class Meta: unique_together = [("locale", "created_at")] diff --git a/pontoon/insights/tasks.py b/pontoon/insights/tasks.py index ecdfd45d98..e8016690a2 100644 --- a/pontoon/insights/tasks.py +++ b/pontoon/insights/tasks.py @@ -20,7 +20,7 @@ from pontoon.base.models import Entity, Locale, TranslatedResource, Translation from pontoon.insights.chs import build_chs_snapshots from pontoon.insights.models import ( - LocaleChsSnapshot, + LocaleHealthSnapshot, LocaleInsightsSnapshot, ProjectLocaleInsightsSnapshot, ) @@ -641,7 +641,7 @@ def collect_chs_snapshot(end_date: datetime | None = None): # print("chs", compute_chs(args)) snapshots = build_chs_snapshots(end_date) - LocaleChsSnapshot.objects.bulk_create(snapshots, ignore_conflicts=True) + LocaleHealthSnapshot.objects.bulk_create(snapshots, ignore_conflicts=True) log.info( f"Collected CHS snapshot for {end_date.date()}: {len(snapshots)} snapshots queued." ) From a064a00eb16d052fdc3bc521f564291208ff69cb Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:18:15 -0400 Subject: [PATCH 07/34] dashboard frontend base --- pontoon/dashboard/__init__.py | 0 pontoon/dashboard/static/css/dashboard.css | 62 +++++++ pontoon/dashboard/static/js/dashboard.js | 0 .../templates/dashboard/dashboard.html | 107 ++++++++++++ .../dashboard/widgets/locale_list.html | 63 +++++++ pontoon/dashboard/urls.py | 12 ++ pontoon/dashboard/views.py | 156 ++++++++++++++++++ pontoon/settings/base.py | 19 +++ pontoon/urls.py | 1 + 9 files changed, 420 insertions(+) create mode 100644 pontoon/dashboard/__init__.py create mode 100644 pontoon/dashboard/static/css/dashboard.css create mode 100644 pontoon/dashboard/static/js/dashboard.js create mode 100644 pontoon/dashboard/templates/dashboard/dashboard.html create mode 100644 pontoon/dashboard/templates/dashboard/widgets/locale_list.html create mode 100644 pontoon/dashboard/urls.py create mode 100644 pontoon/dashboard/views.py diff --git a/pontoon/dashboard/__init__.py b/pontoon/dashboard/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pontoon/dashboard/static/css/dashboard.css b/pontoon/dashboard/static/css/dashboard.css new file mode 100644 index 0000000000..ccddc61c10 --- /dev/null +++ b/pontoon/dashboard/static/css/dashboard.css @@ -0,0 +1,62 @@ +.locale-list { + table-layout: fixed; + width: 100%; + + &.item-list { + tbody tr:nth-child(odd) { + background: transparent; + } + tbody tr:nth-child(even) { + background: var(--dark-grey-1); + } + } + + .cell-inner { + display: flex; + flex-direction: column; + + .chs-fraction { + align-self: center; + + &.met { + color: var(--status-translated); + font-weight: bold; + } + } + + .prev { + align-self: flex-end; + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + user-select: none; + justify-content: center; + min-width: 36px; + height: 20px; + + &.up { + color: var(--status-translated); + } + + &.down { + color: var(--status-error); + } + + &.equal { + color: var(--grey-1); + justify-content: center; + } + + .fa-caret-up, + .fa-caret-down { + font-size: 1.4em; + } + } + } + + .no-snapshot { + color: var(--translation-secondary-color); + font-style: italic; + } +} diff --git a/pontoon/dashboard/static/js/dashboard.js b/pontoon/dashboard/static/js/dashboard.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pontoon/dashboard/templates/dashboard/dashboard.html b/pontoon/dashboard/templates/dashboard/dashboard.html new file mode 100644 index 0000000000..0f70425c59 --- /dev/null +++ b/pontoon/dashboard/templates/dashboard/dashboard.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} +{% import 'widgets/heading_info.html' as HeadingInfo %} +{% import 'dashboard/widgets/locale_list.html' as LocaleList %} + +{% block title %}Teams{% endblock %} + +{% block class %}teams{% endblock %} + +{% block heading %} +
+
+

+ {{ + HeadingInfo.heading_item( + title='Community Health Dashboard', + link=url('pontoon.dashboard')) + }} +

+ +
    + {{ + HeadingInfo.details_item( + title='Number of teams', + class='teams-count', + value=locales|length) + }} + {% if top_instances %} + {{ + HeadingInfo.details_item( + title='Most translations', + class='most-translations', + value=top_instances['most_translations'], + value_link=url('pontoon.teams.team', top_instances['most_translations'].code)) + }} + {{ + HeadingInfo.details_item( + title='Most unreviewed', + class='most-suggestions', + value=top_instances['most_suggestions'], + value_link=url('pontoon.teams.team', top_instances['most_suggestions'].code)) + }} + {{ + HeadingInfo.details_item( + title='Most missing strings', + class='missing-strings', + value=top_instances['most_missing'], + value_link=url('pontoon.teams.team', top_instances['most_missing'].code)) + }} + {{ + HeadingInfo.details_item( + title='Most enabled strings', + class='most-strings', + value=top_instances['most_strings'], + value_link=url('pontoon.teams.team', top_instances['most_strings'].code)) + }} + {% endif %} +
+ + {{ HeadingInfo.progress_chart() }} + {{ HeadingInfo.progress_chart_legend(all_locales_stats) }} +
+
+{% endblock %} + +{% block bottom %} +
+
+ +
+ + +
+ + {% if user.is_authenticated %} + + {% endif %} +
+ + {{ LocaleList.header() }} + + {% for locale in locales %} + {% set main_link = url('pontoon.teams.team', locale.code) %} + + {{ LocaleList.item(locale, main_link, curr_snapshot=current_snapshots.get(locale.id), deltas=snapshot_deltas.get(locale.id), columns=columns) }} + {% endfor %} + + {{ LocaleList.footer(request=True, form=form) }} +
+
+{% endblock %} + +{% block extend_css %} + {% stylesheet 'dashboard' %} +{% endblock %} + +{% block extend_js %} + {% javascript 'dashboard' %} +{% endblock %} diff --git a/pontoon/dashboard/templates/dashboard/widgets/locale_list.html b/pontoon/dashboard/templates/dashboard/widgets/locale_list.html new file mode 100644 index 0000000000..71423c0cfa --- /dev/null +++ b/pontoon/dashboard/templates/dashboard/widgets/locale_list.html @@ -0,0 +1,63 @@ +{% macro header(request=False) %} + + + + + + + + + + + + + + + + +{% endmacro %} + +{% macro value_cell(curr_value, delta=None, threshold=None, percent=False) %} + +{% endmacro %} + +{% macro item(locale, main_link, curr_snapshot=None, deltas=None, columns=None, class='limited') %} + + + {% if curr_snapshot %} + {% for field, column in columns.items() %} + {{ value_cell(curr_snapshot[field], deltas[field] if deltas else None, column.threshold, column.percent) }} + {% endfor %} + {% else %} + + {% endif %} + +{% endmacro %} + +{% macro footer(request=False, form=None) %} + + + +
LocaleMTC1C2N1EPPCCHS
+
+ {% if delta is not none %} + + + {% if delta != 0 %} + {{ '+' if delta > 0 }}{{ delta }}{{ '%' if percent }} + {% endif %} + + {% endif %} + + + {{ curr_value|round(2) }}{{ '%' if percent }} + +
+
+ + + No snapshot this month +
+{% endmacro %} diff --git a/pontoon/dashboard/urls.py b/pontoon/dashboard/urls.py new file mode 100644 index 0000000000..e5d0310d28 --- /dev/null +++ b/pontoon/dashboard/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from pontoon.dashboard.views import dashboard + + +urlpatterns = [ + path( + "dashboard/", + dashboard, + name="pontoon.dashboard", + ) +] diff --git a/pontoon/dashboard/views.py b/pontoon/dashboard/views.py new file mode 100644 index 0000000000..bdc638d5e2 --- /dev/null +++ b/pontoon/dashboard/views.py @@ -0,0 +1,156 @@ +from dateutil.relativedelta import relativedelta + +from django.shortcuts import render +from django.utils import timezone + +from pontoon.base.aggregated_stats import get_top_instances +from pontoon.base.models.locale import Locale +from pontoon.base.models.translated_resource import TranslatedResource +from pontoon.insights.chs import KEY_PROJECT_SLUGS +from pontoon.insights.models import LocaleHealthSnapshot +from pontoon.settings.base import ( + ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD, + ALL_CONTRIBUTOR_PEOPLE_THRESHOLD, + MANAGER_PEOPLE_THRESHOLD, + NEW_SIGNUP_PEOPLE_THRESHOLD, + TRANSLATOR_PEOPLE_THRESHOLD, +) +from pontoon.teams.forms import LocaleRequestForm + + +LOCALES = [ + # Top 15 (+es-CL) + "cs", + "de", + "es-AR", + "es-CL", + "es-ES", + "es-MX", + "fr", + "hu", + "id", + "it", + "ja", + "nl", + "pl", + "pt-BR", + "ru", + "zh-CN", + # Top 25 + "tr", + "el", + "zh-TW", + "fi", + "pt-PT", + "sv-SE", + "vi", + "sk", + "ar", + # Romanian + "ro", +] + + +CHS_COLUMNS = { + "active_managers": {"threshold": MANAGER_PEOPLE_THRESHOLD}, + "active_translators": {"threshold": TRANSLATOR_PEOPLE_THRESHOLD}, + "active_contributors": {"threshold": ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD}, + "all_contributors": {"threshold": ALL_CONTRIBUTOR_PEOPLE_THRESHOLD}, + "new_signups": {"threshold": NEW_SIGNUP_PEOPLE_THRESHOLD}, + "key_projects_enabled": {"threshold": len(KEY_PROJECT_SLUGS)}, + "completion": {"threshold": 100, "percent": True}, + "chs": {"threshold": 100}, +} + + +def get_monthly_snapshots(locales, month): + month_start = month.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): + deltas = {} + for locale_id, curr_snapshot in current_snapshots.items(): + prev_snapshot = previous_snapshots.get(locale_id) + locale_deltas = {} + for column_key in CHS_COLUMNS: + 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] = round(curr_value - prev_value, 2) + deltas[locale_id] = locale_deltas + + return deltas + + +def dashboard(request): + """List all localization team CHS scores.""" + + # TODO Limit locale fetching to desired locales + # locales = Locale.objects.visible().prefetch_related( + # "latest_translation__entity__resource", + # "latest_translation__user", + # "latest_translation__approved_user", + # ) + + # TODO Welcome Bryan, or welcome Peiying + + # TODO Include "Last Updated -> Next scheduled update timer" so PMs don't get confused + # TODO Maybe also include a "Run failed" warning or the option to manually run CHS calculation + locales = ( + Locale.objects.filter(code__in=LOCALES) + .prefetch_related( + "latest_translation__entity__resource", + "latest_translation__user", + "latest_translation__approved_user", + ) + .order_by("name") + ) + + # TEMP: the most recent snapshot is from May, so shift the window back one + # month — render the previous month as "current" and the month before as + # "previous". Revert `current_anchor` to `timezone.now().date()` once the + # current month has snapshots. + current_anchor = timezone.now().date().replace(day=1) - relativedelta(days=1) + 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_deltas = get_monthly_snapshot_deltas(current_snapshots, previous_snapshots) + + print("current snapshots: ", current_snapshots) + print("previous snapshots: ", previous_snapshots) + + form = LocaleRequestForm() + + if not locales: + return render(request, "no_projects.html", {"title": "CHS Dashboard"}) + + locale_stats = locales.stats_data_as_dict() + return render( + request, + "dashboard/dashboard.html", + { + "locales": locales, + "current_snapshots": current_snapshots, + "snapshot_deltas": snapshot_deltas, + "columns": CHS_COLUMNS, + "all_locales_stats": TranslatedResource.objects.string_stats(), + "locale_stats": locale_stats, + "form": form, + "top_instances": get_top_instances(locales, locale_stats), + }, + ) diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index ebd239d16b..333b26e4f6 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -236,6 +236,7 @@ def _default_from_email(): "pontoon.base", "pontoon.contributors", "pontoon.checks", + "pontoon.dashboard", "pontoon.insights", "pontoon.localizations", "pontoon.machinery", @@ -508,6 +509,15 @@ def _default_from_email(): ), "output_filename": "css/teams.min.css", }, + "dashboard": { + "source_filenames": ( + "css/heading_info.css", + "css/table.css", + "css/request.css", + "css/dashboard.css", + ), + "output_filename": "css/dashboard.min.css", + }, "sync_log": { "source_filenames": ( "css/table.css", @@ -686,6 +696,15 @@ def _default_from_email(): ), "output_filename": "js/teams.min.js", }, + "dashboard": { + "source_filenames": ( + "js/table.js", + "js/progress-chart.js", + "js/request.js", + "js/dashboard.js", + ), + "output_filename": "js/dashboard.min.js", + }, "sync_log": { "source_filenames": ( "js/sync_log.js", diff --git a/pontoon/urls.py b/pontoon/urls.py index 3197fa38d1..121edef3cc 100644 --- a/pontoon/urls.py +++ b/pontoon/urls.py @@ -79,6 +79,7 @@ def docs_serve(request, path="index.html"): path("", include("pontoon.tour.urls")), path("", include("pontoon.tags.urls")), path("", include("pontoon.search.urls")), + path("", include("pontoon.dashboard.urls")), path("", include("pontoon.sync.urls")), path("", include("pontoon.projects.urls")), path("", include("pontoon.machinery.urls")), From 7df3b426287c237fef35b9d7713c17dae744a39a Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:40:03 -0400 Subject: [PATCH 08/34] add configuration form + button + model changes, add staff authentication --- pontoon/base/forms.py | 10 ++++ .../0117_userprofile_dashboard_locales.py | 24 ++++++++ pontoon/base/models/user_profile.py | 7 +++ pontoon/dashboard/static/css/config.css | 54 +++++++++++++++++ pontoon/dashboard/static/js/config.js | 0 .../dashboard/templates/dashboard/config.html | 46 +++++++++++++++ .../templates/dashboard/dashboard.html | 12 ++-- pontoon/dashboard/urls.py | 9 ++- pontoon/dashboard/views.py | 59 +++++++++++++++---- pontoon/settings/base.py | 4 ++ 10 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 pontoon/base/migrations/0117_userprofile_dashboard_locales.py create mode 100644 pontoon/dashboard/static/css/config.css create mode 100644 pontoon/dashboard/static/js/config.js create mode 100644 pontoon/dashboard/templates/dashboard/config.html diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index 2841893bee..04b4285d02 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -333,6 +333,16 @@ class Meta: fields = ("locales_order",) +class UserCommunityHealthDashboardConfigForm(forms.ModelForm): + """ + Form is responsible for saving custom configurations of the Community Health Dashboard. + """ + + class Meta: + model = UserProfile + fields = ("dashboard_locales",) + + class GetEntitiesForm(forms.Form): """ Form for parameters to the `entities` view. diff --git a/pontoon/base/migrations/0117_userprofile_dashboard_locales.py b/pontoon/base/migrations/0117_userprofile_dashboard_locales.py new file mode 100644 index 0000000000..bcab6de052 --- /dev/null +++ b/pontoon/base/migrations/0117_userprofile_dashboard_locales.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.14 on 2026-06-11 19:42 + +import django.contrib.postgres.fields + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("base", "0116_remove_systran_locale_fields"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="dashboard_locales", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.PositiveIntegerField(), + blank=True, + default=list, + size=None, + ), + ), + ] diff --git a/pontoon/base/models/user_profile.py b/pontoon/base/models/user_profile.py index 43b1ce45da..ba988b2a10 100644 --- a/pontoon/base/models/user_profile.py +++ b/pontoon/base/models/user_profile.py @@ -134,6 +134,13 @@ class EmailFrequencies(models.TextChoices): search_match_whole_word = models.BooleanField(default=False) search_rejected_translations = models.BooleanField(default=False) + # Dashboard configurations + dashboard_locales = ArrayField( + models.PositiveIntegerField(), + default=list, + blank=True, + ) + # Used to redirect a user to a custom team page. custom_homepage = models.CharField(max_length=20, blank=True, null=True) diff --git a/pontoon/dashboard/static/css/config.css b/pontoon/dashboard/static/css/config.css new file mode 100644 index 0000000000..cdb6963f36 --- /dev/null +++ b/pontoon/dashboard/static/css/config.css @@ -0,0 +1,54 @@ +.admin-project { + overflow: auto; + position: relative; +} + +.admin-project .select { + padding-left: 0; + text-align: left; +} + +.admin-project h1 { + color: var(--white-1); + font-size: 48px; + letter-spacing: -1px; + margin-bottom: 40px; + position: relative; +} + +.admin-project > form { + color: var(--light-grey-7); + font-size: 14px; + padding: 40px 20px 100px; +} + +.admin-project form > div { + margin: 20px 0; + text-align: right; +} + +.admin-project form > div.controls:last-child { + margin-top: 70px; +} + +.admin-project form > section > div { + margin-top: 20px; +} + +.admin-project h3 { + font-size: 30px; + margin-top: 70px; +} + +.admin-project form .button { + float: right; +} + +.admin-project .controls .button { + width: 150px; +} + +.admin-project form .locales-pretranslate .item.select.selected { + float: none; + margin: 0 49px; +} diff --git a/pontoon/dashboard/static/js/config.js b/pontoon/dashboard/static/js/config.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pontoon/dashboard/templates/dashboard/config.html b/pontoon/dashboard/templates/dashboard/config.html new file mode 100644 index 0000000000..1d0263aeaa --- /dev/null +++ b/pontoon/dashboard/templates/dashboard/config.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% import 'widgets/multiple_item_selector.html' as multiple_item_selector %} + +{% block title %}{{ '%(subtitle)s' | format(subtitle=subtitle) }}{% endblock %} + +{% block class %}admin-project{% endblock %} + +{% block middle %} +
+

Dashboard Configuration

+ + {% csrf_token %} + +
+

+ Target Locales +

+
+ {{ + multiple_item_selector.render( + available_locales, + selected_locales, + form_field='dashboard_locales', + ) + }} +
+
+ +
+ +
+
+{% endblock %} + +{% block extend_css %} + {% stylesheet 'dashboard' %} +{% endblock %} + +{% block extend_js %} + {% javascript 'dashboard' %} +{% endblock %} diff --git a/pontoon/dashboard/templates/dashboard/dashboard.html b/pontoon/dashboard/templates/dashboard/dashboard.html index 0f70425c59..8fd249faf1 100644 --- a/pontoon/dashboard/templates/dashboard/dashboard.html +++ b/pontoon/dashboard/templates/dashboard/dashboard.html @@ -77,12 +77,12 @@

data-filter="td:nth-child(1)" /> - - {% if user.is_authenticated %} - - {% endif %} + Edit Configuration {{ LocaleList.header() }} diff --git a/pontoon/dashboard/urls.py b/pontoon/dashboard/urls.py index e5d0310d28..e22e92708a 100644 --- a/pontoon/dashboard/urls.py +++ b/pontoon/dashboard/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from pontoon.dashboard.views import dashboard +from pontoon.dashboard.views import dashboard, dashboard_config urlpatterns = [ @@ -8,5 +8,10 @@ "dashboard/", dashboard, name="pontoon.dashboard", - ) + ), + path( + "dashboard/config", + dashboard_config, + name="pontoon.dashboard.config", + ), ] diff --git a/pontoon/dashboard/views.py b/pontoon/dashboard/views.py index bdc638d5e2..aebfcb8d5a 100644 --- a/pontoon/dashboard/views.py +++ b/pontoon/dashboard/views.py @@ -1,9 +1,13 @@ from dateutil.relativedelta import relativedelta +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied from django.shortcuts import render from django.utils import timezone from pontoon.base.aggregated_stats import get_top_instances +from pontoon.base.forms import UserCommunityHealthDashboardConfigForm from pontoon.base.models.locale import Locale from pontoon.base.models.translated_resource import TranslatedResource from pontoon.insights.chs import KEY_PROJECT_SLUGS @@ -97,6 +101,42 @@ def get_monthly_snapshot_deltas(current_snapshots, previous_snapshots): return deltas +@login_required(redirect_field_name="", login_url="/403") +def dashboard_config(request): + """Configure which locales appear on the CHS dashboard.""" + user = request.user + profile = user.profile + + if not user.is_staff: + raise PermissionDenied + + if request.method == "POST": + dashboard_locales_form = UserCommunityHealthDashboardConfigForm( + request.POST, instance=profile + ) + + if dashboard_locales_form.is_valid(): + dashboard_locales_form.save() + messages.success(request, "Configuration saved.") + + 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) + print(selected_locales) + print(available_locales) + return render( + request, + "dashboard/config.html", + { + "available_locales": available_locales, + "selected_locales": selected_locales, + }, + ) + + +@login_required(redirect_field_name="", login_url="/403") def dashboard(request): """List all localization team CHS scores.""" @@ -106,20 +146,18 @@ def dashboard(request): # "latest_translation__user", # "latest_translation__approved_user", # ) + user = request.user + profile = user.profile + + if not user.is_staff: + raise PermissionDenied # TODO Welcome Bryan, or welcome Peiying # TODO Include "Last Updated -> Next scheduled update timer" so PMs don't get confused # TODO Maybe also include a "Run failed" warning or the option to manually run CHS calculation - locales = ( - Locale.objects.filter(code__in=LOCALES) - .prefetch_related( - "latest_translation__entity__resource", - "latest_translation__user", - "latest_translation__approved_user", - ) - .order_by("name") - ) + dashboard_locales = profile.dashboard_locales + locales = Locale.objects.visible().filter(pk__in=dashboard_locales) # TEMP: the most recent snapshot is from May, so shift the window back one # month — render the previous month as "current" and the month before as @@ -136,9 +174,6 @@ def dashboard(request): form = LocaleRequestForm() - if not locales: - return render(request, "no_projects.html", {"title": "CHS Dashboard"}) - locale_stats = locales.stats_data_as_dict() return render( request, diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index 333b26e4f6..8b4c64a2d2 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -514,7 +514,9 @@ def _default_from_email(): "css/heading_info.css", "css/table.css", "css/request.css", + "css/multiple_item_selector.css", "css/dashboard.css", + "css/config.css", ), "output_filename": "css/dashboard.min.css", }, @@ -701,7 +703,9 @@ def _default_from_email(): "js/table.js", "js/progress-chart.js", "js/request.js", + "js/multiple_item_selector.js", "js/dashboard.js", + "js/config.js", ), "output_filename": "js/dashboard.min.js", }, From 9da467adb61cbe5eb911969ade7d593eda413738 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:42:05 -0400 Subject: [PATCH 09/34] add score/default view, change fields to Decimal, reform css, improve existing table sorting --- pontoon/base/static/js/table.js | 6 +- pontoon/dashboard/static/css/config.css | 106 ++++++++++-------- pontoon/dashboard/static/css/dashboard.css | 39 ++++++- pontoon/dashboard/static/js/dashboard.js | 21 ++++ .../dashboard/templates/dashboard/config.html | 1 + .../templates/dashboard/dashboard.html | 1 - .../dashboard/widgets/locale_list.html | 84 ++++++++++---- pontoon/dashboard/views.py | 100 ++++++++++++----- pontoon/insights/chs.py | 10 +- ...shot_active_contributors_score_and_more.py | 57 ++++++++++ pontoon/insights/models.py | 28 +++-- 11 files changed, 335 insertions(+), 118 deletions(-) create mode 100644 pontoon/insights/migrations/0025_alter_localehealthsnapshot_active_contributors_score_and_more.py diff --git a/pontoon/base/static/js/table.js b/pontoon/base/static/js/table.js index 1a598fcb6d..0fa5de621c 100644 --- a/pontoon/base/static/js/table.js +++ b/pontoon/base/static/js/table.js @@ -190,7 +190,11 @@ var Pontoon = (function (my) { } function getSort(el) { - return parseInt($(el).find('[data-sort]').data('sort'), 10) || 0; + const cell = $(el).find('td').eq(index); + const holder = cell.is('[data-sort]') + ? cell + : cell.find('[data-sort]').first(); + return parseFloat(holder.attr('data-sort')) || 0; } function getString(el) { diff --git a/pontoon/dashboard/static/css/config.css b/pontoon/dashboard/static/css/config.css index cdb6963f36..317d1a5903 100644 --- a/pontoon/dashboard/static/css/config.css +++ b/pontoon/dashboard/static/css/config.css @@ -1,54 +1,62 @@ .admin-project { overflow: auto; position: relative; -} - -.admin-project .select { - padding-left: 0; - text-align: left; -} - -.admin-project h1 { - color: var(--white-1); - font-size: 48px; - letter-spacing: -1px; - margin-bottom: 40px; - position: relative; -} - -.admin-project > form { - color: var(--light-grey-7); - font-size: 14px; - padding: 40px 20px 100px; -} - -.admin-project form > div { - margin: 20px 0; - text-align: right; -} - -.admin-project form > div.controls:last-child { - margin-top: 70px; -} - -.admin-project form > section > div { - margin-top: 20px; -} - -.admin-project h3 { - font-size: 30px; - margin-top: 70px; -} - -.admin-project form .button { - float: right; -} - -.admin-project .controls .button { - width: 150px; -} -.admin-project form .locales-pretranslate .item.select.selected { - float: none; - margin: 0 49px; + .select { + padding-left: 0; + text-align: left; + } + + h1 { + color: var(--white-1); + font-size: 48px; + letter-spacing: -1px; + margin-bottom: 40px; + position: relative; + } + + h3 { + font-size: 30px; + margin-top: 70px; + } + + > form { + color: var(--light-grey-7); + font-size: 14px; + padding: 40px 20px 100px; + + > div { + margin: 20px 0; + text-align: right; + + &.controls:last-child { + margin-top: 70px; + } + } + + > section > div { + margin-top: 20px; + } + + .button { + float: right; + } + + .locales-pretranslate .item.select.selected { + float: none; + margin: 0 49px; + } + + .controls { + .button { + width: 150px; + } + + .cancel { + color: var(--status-translated-alt); + float: right; + margin: 5px 9px; + } + } + } } diff --git a/pontoon/dashboard/static/css/dashboard.css b/pontoon/dashboard/static/css/dashboard.css index ccddc61c10..46bb2aedb0 100644 --- a/pontoon/dashboard/static/css/dashboard.css +++ b/pontoon/dashboard/static/css/dashboard.css @@ -1,3 +1,25 @@ +menu { + display: flex; + .button-container { + display: flex; + margin-left: auto; + align-items: flex-end; + gap: 8px; + + #edit-config { + width: 160px; + margin-right: auto; + user-select: none; + } + + #view-toggle { + width: 120px; + margin-left: auto; + user-select: none; + } + } +} + .locale-list { table-layout: fixed; width: 100%; @@ -11,11 +33,20 @@ } } - .cell-inner { + &.show-score-view { + .info-container.base-view { + display: none; + } + .info-container.score-view { + display: flex; + } + } + + .info-container { display: flex; flex-direction: column; - .chs-fraction { + .info { align-self: center; &.met { @@ -24,6 +55,10 @@ } } + &.score-view { + display: none; + } + .prev { align-self: flex-end; display: flex; diff --git a/pontoon/dashboard/static/js/dashboard.js b/pontoon/dashboard/static/js/dashboard.js index e69de29bb2..3f159f4148 100644 --- a/pontoon/dashboard/static/js/dashboard.js +++ b/pontoon/dashboard/static/js/dashboard.js @@ -0,0 +1,21 @@ +$('body').on('click', '#view-toggle', function (e) { + e.stopPropagation(); + + const table = $('.locale-list'); + table.toggleClass('show-score-view'); + + const showScores = table.hasClass('show-score-view'); + + // Keep each cells sort key in sync + table.find('td.cell').each(function () { + const td = $(this); + const key = showScores + ? td.attr('data-score-sort') + : td.attr('data-base-sort'); + if (key !== undefined) { + td.attr('data-sort', key); + } + }); + + $('#view-toggle').text(showScores ? 'Show default' : 'Show scores'); +}); diff --git a/pontoon/dashboard/templates/dashboard/config.html b/pontoon/dashboard/templates/dashboard/config.html index 1d0263aeaa..df66f899bb 100644 --- a/pontoon/dashboard/templates/dashboard/config.html +++ b/pontoon/dashboard/templates/dashboard/config.html @@ -33,6 +33,7 @@

+ Cancel
{% endblock %} diff --git a/pontoon/dashboard/templates/dashboard/dashboard.html b/pontoon/dashboard/templates/dashboard/dashboard.html index 8fd249faf1..a520a4063b 100644 --- a/pontoon/dashboard/templates/dashboard/dashboard.html +++ b/pontoon/dashboard/templates/dashboard/dashboard.html @@ -90,7 +90,6 @@

{% for locale in locales %} {% set main_link = url('pontoon.teams.team', locale.code) %} - {{ LocaleList.item(locale, main_link, curr_snapshot=current_snapshots.get(locale.id), deltas=snapshot_deltas.get(locale.id), columns=columns) }} {% endfor %} {{ LocaleList.footer(request=True, form=form) }} diff --git a/pontoon/dashboard/templates/dashboard/widgets/locale_list.html b/pontoon/dashboard/templates/dashboard/widgets/locale_list.html index 71423c0cfa..6ad62d0230 100644 --- a/pontoon/dashboard/templates/dashboard/widgets/locale_list.html +++ b/pontoon/dashboard/templates/dashboard/widgets/locale_list.html @@ -3,40 +3,61 @@ Locale - M - T - C1 - C2 - N1 - EP - PC - CHS + M + T + C1 + C2 + N1 + EP + PC + CHS {% endmacro %} -{% macro value_cell(curr_value, delta=None, threshold=None, percent=False) %} - -
- {% if delta is not none %} - - - {% if delta != 0 %} - {{ '+' if delta > 0 }}{{ delta }}{{ '%' if percent }} - {% endif %} - +{% macro delta_span(delta, percent=False) %} + {% if delta is not none %} + + + {% if delta != 0 %} + {{ '+' if delta > 0 }}{{ delta }}{{ '%' if percent }} {% endif %} + + {% endif %} +{% endmacro %} - - {{ curr_value|round(2) }}{{ '%' if percent }} - -
+{% 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, deltas=None, columns=None, class='limited') %} +{% macro item(locale, main_link, curr_snapshot=None, base_deltas=None, score_deltas=None, columns=None, class='limited') %}
@@ -45,7 +66,22 @@ {% if curr_snapshot %} {% for field, column in columns.items() %} - {{ value_cell(curr_snapshot[field], deltas[field] if deltas else None, column.threshold, column.percent) }} + {% 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 %} diff --git a/pontoon/dashboard/views.py b/pontoon/dashboard/views.py index aebfcb8d5a..6f67d58889 100644 --- a/pontoon/dashboard/views.py +++ b/pontoon/dashboard/views.py @@ -3,7 +3,7 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied -from django.shortcuts import render +from django.shortcuts import redirect, render from django.utils import timezone from pontoon.base.aggregated_stats import get_top_instances @@ -14,10 +14,17 @@ from pontoon.insights.models import LocaleHealthSnapshot 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, ) from pontoon.teams.forms import LocaleRequestForm @@ -54,16 +61,59 @@ "ro", ] +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": {"threshold": MANAGER_PEOPLE_THRESHOLD}, - "active_translators": {"threshold": TRANSLATOR_PEOPLE_THRESHOLD}, - "active_contributors": {"threshold": ACTIVE_CONTRIBUTOR_PEOPLE_THRESHOLD}, - "all_contributors": {"threshold": ALL_CONTRIBUTOR_PEOPLE_THRESHOLD}, - "new_signups": {"threshold": NEW_SIGNUP_PEOPLE_THRESHOLD}, - "key_projects_enabled": {"threshold": len(KEY_PROJECT_SLUGS)}, - "completion": {"threshold": 100, "percent": True}, - "chs": {"threshold": 100}, + "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}, } @@ -84,18 +134,18 @@ def get_monthly_snapshots(locales, month): return latest -def get_monthly_snapshot_deltas(current_snapshots, previous_snapshots): +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 CHS_COLUMNS: + 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] = round(curr_value - prev_value, 2) + locale_deltas[column_key] = curr_value - prev_value deltas[locale_id] = locale_deltas return deltas @@ -118,14 +168,13 @@ def dashboard_config(request): if dashboard_locales_form.is_valid(): dashboard_locales_form.save() messages.success(request, "Configuration saved.") + return redirect("pontoon.dashboard") 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) - print(selected_locales) - print(available_locales) return render( request, "dashboard/config.html", @@ -140,20 +189,12 @@ def dashboard_config(request): def dashboard(request): """List all localization team CHS scores.""" - # TODO Limit locale fetching to desired locales - # locales = Locale.objects.visible().prefetch_related( - # "latest_translation__entity__resource", - # "latest_translation__user", - # "latest_translation__approved_user", - # ) user = request.user profile = user.profile if not user.is_staff: raise PermissionDenied - # TODO Welcome Bryan, or welcome Peiying - # TODO Include "Last Updated -> Next scheduled update timer" so PMs don't get confused # TODO Maybe also include a "Run failed" warning or the option to manually run CHS calculation dashboard_locales = profile.dashboard_locales @@ -167,21 +208,26 @@ def dashboard(request): 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_deltas = get_monthly_snapshot_deltas(current_snapshots, previous_snapshots) - - print("current snapshots: ", current_snapshots) - print("previous snapshots: ", previous_snapshots) + 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 + ) form = LocaleRequestForm() locale_stats = locales.stats_data_as_dict() + print(snapshot_base_deltas) + print(snapshot_score_deltas) return render( request, "dashboard/dashboard.html", { "locales": locales, "current_snapshots": current_snapshots, - "snapshot_deltas": snapshot_deltas, + "snapshot_base_deltas": snapshot_base_deltas, + "snapshot_score_deltas": snapshot_score_deltas, "columns": CHS_COLUMNS, "all_locales_stats": TranslatedResource.objects.string_stats(), "locale_stats": locale_stats, diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index a870a3e8a3..c834cf1057 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -202,7 +202,7 @@ def compute_chs(args: dict) -> float: all_contributors = args.get("all_contributors", 0) new_signups = args.get("new_signups", 0) key_projects_enabled = args.get("key_projects_enabled", 0) - completion = args.get("completion", 0.0) + completion = args.get("completion", 0.00) total_manager_points = MANAGER_POINTS if active_managers >= 1 else 0 @@ -234,10 +234,10 @@ def compute_chs(args: dict) -> float: else: total_new_signup_points = 0 - total_enabled_project_points = ( - key_projects_enabled / len(KEY_PROJECT_SLUGS) - ) * ENABLED_PROJECT_POINTS - total_completion_points = round((completion / 100) * COMPLETION_POINTS, 1) + total_enabled_project_points = round( + (key_projects_enabled / len(KEY_PROJECT_SLUGS)) * ENABLED_PROJECT_POINTS, 2 + ) + total_completion_points = round((completion / 100) * COMPLETION_POINTS, 2) chs = round( total_manager_points diff --git a/pontoon/insights/migrations/0025_alter_localehealthsnapshot_active_contributors_score_and_more.py b/pontoon/insights/migrations/0025_alter_localehealthsnapshot_active_contributors_score_and_more.py new file mode 100644 index 0000000000..64edee97b8 --- /dev/null +++ b/pontoon/insights/migrations/0025_alter_localehealthsnapshot_active_contributors_score_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.14 on 2026-06-12 19:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("insights", "0024_rename_chs_score_localehealthsnapshot_chs_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="localehealthsnapshot", + name="active_contributors_score", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="active_managers_score", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="active_translators_score", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="all_contributors_score", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="chs", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="completion", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="completion_score", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="key_projects_enabled_score", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + migrations.AlterField( + model_name="localehealthsnapshot", + name="new_signups_score", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + ] diff --git a/pontoon/insights/models.py b/pontoon/insights/models.py index 50616e2c86..c9cbc68f7b 100644 --- a/pontoon/insights/models.py +++ b/pontoon/insights/models.py @@ -88,21 +88,31 @@ class ProjectLocaleInsightsSnapshot(InsightsSnapshot): class LocaleHealthSnapshot(models.Model): locale = models.ForeignKey("base.Locale", on_delete=models.CASCADE) created_at = models.DateField() - completion = models.FloatField(default=0.0) + completion = models.DecimalField(max_digits=5, decimal_places=2, default=0) key_projects_enabled = models.PositiveIntegerField(default=0) active_managers = models.PositiveIntegerField(default=0) active_translators = models.PositiveIntegerField(default=0) active_contributors = models.PositiveIntegerField(default=0) all_contributors = models.PositiveIntegerField(default=0) new_signups = models.PositiveIntegerField(default=0) - completion_score = models.FloatField(default=0.0) - key_projects_enabled_score = models.FloatField(default=0) - active_managers_score = models.FloatField(default=0) - active_translators_score = models.FloatField(default=0) - active_contributors_score = models.FloatField(default=0) - all_contributors_score = models.FloatField(default=0) - new_signups_score = models.FloatField(default=0) - chs = models.FloatField(default=0.0) + completion_score = models.DecimalField(max_digits=5, decimal_places=2, default=0) + key_projects_enabled_score = models.DecimalField( + max_digits=5, decimal_places=2, default=0 + ) + active_managers_score = models.DecimalField( + max_digits=5, decimal_places=2, default=0 + ) + active_translators_score = models.DecimalField( + max_digits=5, decimal_places=2, default=0 + ) + active_contributors_score = models.DecimalField( + max_digits=5, decimal_places=2, default=0 + ) + all_contributors_score = models.DecimalField( + max_digits=5, decimal_places=2, default=0 + ) + new_signups_score = models.DecimalField(max_digits=5, decimal_places=2, default=0) + chs = models.DecimalField(max_digits=5, decimal_places=2, default=0) class Meta: unique_together = [("locale", "created_at")] From 8dd04bda7b5a976df1212a3e5bb916605299abec Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:42:30 -0400 Subject: [PATCH 10/34] missing dashboard.html addition --- .../templates/dashboard/dashboard.html | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/pontoon/dashboard/templates/dashboard/dashboard.html b/pontoon/dashboard/templates/dashboard/dashboard.html index a520a4063b..d73f6270ff 100644 --- a/pontoon/dashboard/templates/dashboard/dashboard.html +++ b/pontoon/dashboard/templates/dashboard/dashboard.html @@ -2,7 +2,7 @@ {% import 'widgets/heading_info.html' as HeadingInfo %} {% import 'dashboard/widgets/locale_list.html' as LocaleList %} -{% block title %}Teams{% endblock %} +{% block title %}Community Health Dashboard{% endblock %} {% block class %}teams{% endblock %} @@ -12,7 +12,7 @@

{{ HeadingInfo.heading_item( - title='Community Health Dashboard', + title='Hey, ' ~ user.name_or_email ~ '!', link=url('pontoon.dashboard')) }}

@@ -77,12 +77,15 @@

data-filter="td:nth-child(1)" />

- Edit Configuration +
+ Edit Configuration + + +
{{ LocaleList.header() }} @@ -90,6 +93,7 @@

{% for locale in locales %} {% set main_link = url('pontoon.teams.team', locale.code) %} + {{ LocaleList.item(locale, main_link, curr_snapshot=current_snapshots.get(locale.id), base_deltas=snapshot_base_deltas.get(locale.id), score_deltas=snapshot_score_deltas.get(locale.id), columns=columns) }} {% endfor %} {{ LocaleList.footer(request=True, form=form) }} From 93d6417bb9c99315656582892e39e7d2fbbc84c8 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:14:50 -0400 Subject: [PATCH 11/34] add no locale check, order locales by code --- pontoon/dashboard/static/css/dashboard.css | 6 ++++++ pontoon/dashboard/templates/dashboard/dashboard.html | 6 ++++++ pontoon/dashboard/views.py | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pontoon/dashboard/static/css/dashboard.css b/pontoon/dashboard/static/css/dashboard.css index 46bb2aedb0..aa59e26501 100644 --- a/pontoon/dashboard/static/css/dashboard.css +++ b/pontoon/dashboard/static/css/dashboard.css @@ -95,3 +95,9 @@ menu { font-style: italic; } } + +.no-locales { + color: var(--light-grey-7); + font-style: italic; + padding: 10px 16px; +} diff --git a/pontoon/dashboard/templates/dashboard/dashboard.html b/pontoon/dashboard/templates/dashboard/dashboard.html index d73f6270ff..02cbb1cddd 100644 --- a/pontoon/dashboard/templates/dashboard/dashboard.html +++ b/pontoon/dashboard/templates/dashboard/dashboard.html @@ -97,6 +97,12 @@

{% endfor %} {{ LocaleList.footer(request=True, form=form) }} + + {% if not locales %} +
+

No locales selected.

+
+ {% endif %} {% endblock %} diff --git a/pontoon/dashboard/views.py b/pontoon/dashboard/views.py index 6f67d58889..24a0d686c6 100644 --- a/pontoon/dashboard/views.py +++ b/pontoon/dashboard/views.py @@ -198,7 +198,7 @@ def dashboard(request): # TODO Include "Last Updated -> Next scheduled update timer" so PMs don't get confused # TODO Maybe also include a "Run failed" warning or the option to manually run CHS calculation dashboard_locales = profile.dashboard_locales - locales = Locale.objects.visible().filter(pk__in=dashboard_locales) + locales = Locale.objects.visible().filter(pk__in=dashboard_locales).order_by("code") # TEMP: the most recent snapshot is from May, so shift the window back one # month — render the previous month as "current" and the month before as From 56f737f03193792639e9cb3817cea24dbb562416 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:08:57 -0400 Subject: [PATCH 12/34] full feature parity with scripts --- pontoon/insights/chs.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index c834cf1057..c151566198 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -94,7 +94,7 @@ def get_contributor_metrics_by_locale(locales, end_date: datetime) -> dict[int, Per-locale active-contributor counts over the 12-month window ending at end_dt. """ start_date = end_date - relativedelta(months=13) - (print("start date", start_date),) + print("start date", start_date) print("end date", end_date) managers = defaultdict(set) @@ -131,6 +131,7 @@ def get_contributor_metrics_by_locale(locales, end_date: datetime) -> dict[int, Translation.objects.filter( locale__in=locales, user__isnull=False, + user__is_active=True, user__profile__system_user=False, date__gte=start_date, date__lte=end_date, @@ -168,11 +169,6 @@ def get_contributor_metrics_by_locale(locales, end_date: datetime) -> dict[int, action_count = action_counts.get((user_id, locale_id), 0) - if locale_id == 185: - print("user_id", user_id) - print("total", total) - print("approved", approved) - if not total: continue @@ -250,15 +246,6 @@ def compute_chs(args: dict) -> float: 2, ) - print("total manager points:", total_manager_points) - print("total translator points:", total_translator_points) - print("total active contributor points:", total_active_contributor_points) - print("total all contributor points:", total_all_contributor_points) - print("total new signup points:", total_new_signup_points) - print("total enabled project points:", total_enabled_project_points) - print("total completion points:", total_completion_points) - print("CHS score:", chs) - chs_fields = { "completion_score": total_completion_points, "key_projects_enabled_score": total_enabled_project_points, From 56a592977193923fb4ca592cf947597e66dca9c5 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:22:48 -0400 Subject: [PATCH 13/34] add community health activity charts to Locale and Global insights --- pontoon/insights/static/css/insights_tab.css | 4 + pontoon/insights/static/js/insights.js | 15 ++- pontoon/insights/static/js/insights_tab.js | 93 ++++++++++++++++ .../insights/templates/insights/insights.html | 31 +++++- .../templates/insights/widgets/insights.html | 68 ++++++++---- pontoon/insights/utils.py | 104 ++++++++++++++---- pontoon/insights/views.py | 14 ++- pontoon/teams/views.py | 6 +- 8 files changed, 284 insertions(+), 51 deletions(-) diff --git a/pontoon/insights/static/css/insights_tab.css b/pontoon/insights/static/css/insights_tab.css index 941a28352c..c61a7a17b7 100644 --- a/pontoon/insights/static/css/insights_tab.css +++ b/pontoon/insights/static/css/insights_tab.css @@ -93,6 +93,10 @@ color: var(--status-unreviewed); } +#insights .community-health-activity h3 .tooltip li.current-month::marker { + color: var(--status-translated); +} + #insights h3 .tooltip li.twelve-month-average::marker { color: var(--grey-5); } diff --git a/pontoon/insights/static/js/insights.js b/pontoon/insights/static/js/insights.js index af7b5219bd..b6684d1ba7 100644 --- a/pontoon/insights/static/js/insights.js +++ b/pontoon/insights/static/js/insights.js @@ -14,14 +14,20 @@ var Pontoon = (function (my) { return $.extend(true, my, { insights: { renderCharts: function () { - Pontoon.insights.renderPretranslationQualityChart( + Pontoon.insights.renderGlobalChart( + $('#team-community-health-chart'), + 'chs', + ); + Pontoon.insights.renderGlobalChart( $('#team-pretranslation-quality-chart'), + 'approval_rate', ); - Pontoon.insights.renderPretranslationQualityChart( + Pontoon.insights.renderGlobalChart( $('#project-pretranslation-quality-chart'), + 'approval_rate', ); }, - renderPretranslationQualityChart: function (chart) { + renderGlobalChart: function (chart, key) { if (chart.length === 0) { return; } @@ -47,7 +53,7 @@ var Pontoon = (function (my) { return { type: 'line', label: item.name, - data: item.approval_rate, + data: item[key], borderColor: [color], borderWidth: item.name === 'All' ? 3 : 1, pointBackgroundColor: color, @@ -60,6 +66,7 @@ var Pontoon = (function (my) { fill: true, tension: 0.4, order: color.length - index, + hidden: true, }; }); diff --git a/pontoon/insights/static/js/insights_tab.js b/pontoon/insights/static/js/insights_tab.js index 1df745f6e6..9e5f4749f6 100644 --- a/pontoon/insights/static/js/insights_tab.js +++ b/pontoon/insights/static/js/insights_tab.js @@ -17,6 +17,7 @@ var Pontoon = (function (my) { Pontoon.insights.renderTranslationActivity(); Pontoon.insights.renderReviewActivity(); Pontoon.insights.renderPretranslationQuality(); + Pontoon.insights.renderCommunityHealthScore(); }, renderActiveUsers: function () { $('#insights canvas.chart').each(function () { @@ -1002,6 +1003,98 @@ var Pontoon = (function (my) { plugins: [Pontoon.insights.htmlLegendPlugin()], }); }, + renderCommunityHealthScore: function () { + const chart = $('#community-health-chart'); + if (chart.length === 0) { + return; + } + const ctx = chart[0].getContext('2d'); + + const gradient = ctx.createLinearGradient(0, 0, 0, 400); + const green = style.getPropertyValue('--status-translated'); + gradient.addColorStop(0, green); + gradient.addColorStop(1, 'transparent'); + + new Chart(chart, { + type: 'line', + data: { + labels: $('#community-health-chart').data('community-health-dates'), + datasets: [ + { + label: 'Community Health Score', + data: chart.data('community-health-scores'), + backgroundColor: gradient, + borderColor: [style.getPropertyValue('--status-translated')], + borderWidth: 2, + pointBackgroundColor: style.getPropertyValue( + '--status-translated', + ), + pointHitRadius: 10, + pointRadius: 3.25, + pointHoverRadius: 6, + pointHoverBackgroundColor: style.getPropertyValue( + '--status-translated', + ), + pointHoverBorderColor: style.getPropertyValue('--white-1'), + fill: true, + tension: 0.4, + }, + ], + }, + options: { + plugins: { + tooltip: { + borderColor: style.getPropertyValue('--status-translated'), + borderWidth: 1, + caretPadding: 5, + padding: { + x: 10, + y: 10, + }, + displayColors: false, + callbacks: { + label: (context) => nf.format(context.parsed.y), + }, + }, + }, + scales: { + x: { + type: 'time', + time: { + unit: 'month', + displayFormats: { + month: 'MMM', + }, + tooltipFormat: 'MMMM yyyy', + }, + grid: { + display: false, + }, + ticks: { + source: 'data', + }, + }, + y: { + title: { + display: true, + text: 'CHS', + color: style.getPropertyValue('--white-1'), + fontStyle: 100, + }, + beginAtZero: true, + grid: { + display: false, + }, + position: 'right', + ticks: { + maxTicksLimit: 3, + precision: 0, + }, + }, + }, + }, + }); + }, getPercent: function (value, total) { const n = value / total; return pf.format(isFinite(n) ? n : 0); diff --git a/pontoon/insights/templates/insights/insights.html b/pontoon/insights/templates/insights/insights.html index ce1ab6b79b..1b8e275a1b 100644 --- a/pontoon/insights/templates/insights/insights.html +++ b/pontoon/insights/templates/insights/insights.html @@ -14,6 +14,29 @@
+ {% if global_locale_health_insights %} +
+
+

+ Team community health activity + {{ Tooltip.display(intro='Community health score for each team.') }} +

+
+ + +
+
+
+
+ {% endif %}

@@ -23,8 +46,8 @@

@@ -44,8 +67,8 @@

diff --git a/pontoon/insights/templates/insights/widgets/insights.html b/pontoon/insights/templates/insights/widgets/insights.html index 001724a2b9..0740de16d7 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 %}

@@ -103,7 +103,7 @@

@@ -133,8 +133,8 @@

@@ -145,6 +145,34 @@

{% endif %} + {% if community_health_scores %} +
+
+

+ Community health activity + {{ + 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/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..4c6ee9a118 100644 --- a/pontoon/insights/views.py +++ b/pontoon/insights/views.py @@ -8,7 +8,11 @@ from django.shortcuts import render from django.utils import timezone -from pontoon.insights.utils import get_global_pretranslation_quality +from pontoon.base.models.locale import Locale +from pontoon.insights.utils import ( + get_global_locale_health_insights, + get_global_pretranslation_quality, +) log = logging.getLogger(__name__) @@ -41,6 +45,13 @@ def insights(request): project_pt_key, project_pretranslation_quality, settings.VIEW_CACHE_TIMEOUT ) + global_locale_health_insights = [] + user = request.user + if user.is_staff: + locale_ids = user.profile.dashboard_locales + locales = Locale.objects.filter(pk__in=locale_ids) + global_locale_health_insights = get_global_locale_health_insights(locales) + return render( request, "insights/insights.html", @@ -49,5 +60,6 @@ def insights(request): "end_date": timezone.now(), "team_pretranslation_quality": team_pretranslation_quality, "project_pretranslation_quality": project_pretranslation_quality, + "global_locale_health_insights": global_locale_health_insights, }, ) 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) From 1ec9668c690446d222a8f91fb71eadb225cf5948 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:18:47 -0400 Subject: [PATCH 14/34] small snapshot logic, docs + DX changes --- documentation/docs/dev/deployment.md | 2 +- pontoon/insights/chs.py | 13 +++--- .../commands/collect_chs_snapshot.py | 43 +++++++++++++------ pontoon/insights/tasks.py | 28 +++--------- pontoon/settings/base.py | 5 +++ 5 files changed, 48 insertions(+), 43 deletions(-) diff --git a/documentation/docs/dev/deployment.md b/documentation/docs/dev/deployment.md index ac9642d97b..2709c5a963 100644 --- a/documentation/docs/dev/deployment.md +++ b/documentation/docs/dev/deployment.md @@ -503,7 +503,7 @@ comparisons and by the Insights pages for monthly trend charts. The job is designed to run once a month on the first of each month. ``` bash -./manage.py collect_chs_snapshot +./manage.py collect_chs_snapshots ``` ### Warm up cache diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index c151566198..a8b5ff85f6 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -4,6 +4,7 @@ from dateutil.relativedelta import relativedelta from django.db.models import Count, F, Q, Sum +from django.utils import timezone from pontoon.actionlog.models import ActionLog from pontoon.base.models import Locale, TranslatedResource, Translation @@ -260,13 +261,15 @@ def compute_chs(args: dict) -> float: return chs_fields -def build_chs_snapshots(end_date: datetime) -> list[LocaleHealthSnapshot]: - """Assemble one LocaleHealthSnapshot per available locale for dt_max.""" +def build_chs_snapshots() -> list[LocaleHealthSnapshot]: + """Assemble one LocaleHealthSnapshot per visible locale for today.""" + + now = timezone.now() locales = Locale.objects.visible() completion = get_completion_by_locale(locales) enabled = get_key_projects_enabled_by_locale(locales, KEY_PROJECT_SLUGS) - contributors = get_contributor_metrics_by_locale(locales, end_date) + contributors = get_contributor_metrics_by_locale(locales, now) snapshots = [] for locale in locales: @@ -283,9 +286,7 @@ def build_chs_snapshots(end_date: datetime) -> list[LocaleHealthSnapshot]: chs_fields = compute_chs(args) snapshots.append( - LocaleHealthSnapshot( - locale=locale, created_at=end_date, **args, **chs_fields - ) + LocaleHealthSnapshot(locale=locale, created_at=now, **args, **chs_fields) ) return snapshots diff --git a/pontoon/insights/management/commands/collect_chs_snapshot.py b/pontoon/insights/management/commands/collect_chs_snapshot.py index d26fdb6854..d3ce11baa2 100644 --- a/pontoon/insights/management/commands/collect_chs_snapshot.py +++ b/pontoon/insights/management/commands/collect_chs_snapshot.py @@ -1,24 +1,39 @@ -from datetime import datetime - +from django.conf import settings from django.core.management.base import BaseCommand -from django.utils import timezone +from django.utils.timezone import now -from pontoon.insights.tasks import collect_chs_snapshot +from pontoon.insights.tasks import collect_chs_snapshots class Command(BaseCommand): - help = "Collect monthly CHS snapshot, one row per locale." + help = "Collect monthly CHS snapshots for all visible locales." + + def add_arguments(self, parser): + parser.add_argument( + "--force", + action="store_true", + help="Force sending regardless of the current date.", + ) def handle(self, *args, **options): """ - Per-locale Contributor Health Score (CHS) metrics — completion, key-project - enablement, active managers / translators / contributors, new signups — - snapshotted once per month. The dashboard reads two snapshots (current month - and the previous one) to render month-over-month deltas; the Insights tab - reads 12 snapshots for the trend chart. + Collects per-locale Community Health Score (CHS) m etrics, saved as + LocaleHealthSnapshots. - Designed to run on the 1st of every month. + The metrics involve: + - project completion + - key projects enabled + - active managers + - active translators + - active contributors + - all contributors + - new signups + + Only send on the given day of the month or when --force is used. """ - collect_chs_snapshot( - end_date=timezone.make_aware(datetime(year=2026, month=5, day=22)) - ) + if options["force"] or now().day == settings.MONTHLY_CHS_SNAPSHOTS_DAY: + collect_chs_snapshots.delay() + else: + self.stdout.write( + f"This command can only be run on day {settings.MONTHLY_CHS_SNAPSHOTS_DAY} of the month. Use --force to bypass." + ) diff --git a/pontoon/insights/tasks.py b/pontoon/insights/tasks.py index e8016690a2..63dcc0f699 100644 --- a/pontoon/insights/tasks.py +++ b/pontoon/insights/tasks.py @@ -620,28 +620,12 @@ def get_active_users( @shared_task -def collect_chs_snapshot(end_date: datetime | None = None): - """Collect a monthly CHS snapshot, one row per available locale.""" +def collect_chs_snapshots(): + """Collect monthly LocaleHealthSnapshots (CHS), one per available locale.""" - if end_date is None: - end_date = timezone.now().replace( - day=1, hour=0, minute=0, second=0, microsecond=0 - ) - - # args = { - # "completion": 98.11, - # "key_projects_enabled": 6, - # "active_managers": 1, - # "active_translators": 0, - # "active_contributors": 1, - # "active_contributors_200_approved": 0, - # "new_signups": 2, - # } - - # print("chs", compute_chs(args)) + log.info("Start collecting CHS snapshots...") - snapshots = build_chs_snapshots(end_date) + now = timezone.now() + snapshots = build_chs_snapshots() LocaleHealthSnapshot.objects.bulk_create(snapshots, ignore_conflicts=True) - log.info( - f"Collected CHS snapshot for {end_date.date()}: {len(snapshots)} snapshots queued." - ) + log.info(f"Collected CHS snapshots for {now}: {len(snapshots)} snapshots created.") diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index 8b4c64a2d2..568cc0f6f5 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -1235,6 +1235,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) From db983ac179af759d99fff101dda53fffc0e5250d Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:10:58 -0400 Subject: [PATCH 15/34] fix global insights tests --- pontoon/insights/tests/test_views.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pontoon/insights/tests/test_views.py b/pontoon/insights/tests/test_views.py index db5b919991..b262c2fbc9 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 @@ -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": [ @@ -143,7 +141,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 +178,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": [ From 7014e00f6cad563c009ee450a0ac573a54aef242 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:21:07 -0400 Subject: [PATCH 16/34] fix typo --- pontoon/insights/management/commands/collect_chs_snapshot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pontoon/insights/management/commands/collect_chs_snapshot.py b/pontoon/insights/management/commands/collect_chs_snapshot.py index d3ce11baa2..ad6df9b316 100644 --- a/pontoon/insights/management/commands/collect_chs_snapshot.py +++ b/pontoon/insights/management/commands/collect_chs_snapshot.py @@ -17,7 +17,7 @@ def add_arguments(self, parser): def handle(self, *args, **options): """ - Collects per-locale Community Health Score (CHS) m etrics, saved as + Collects per-locale Community Health Score (CHS) metrics, saved as LocaleHealthSnapshots. The metrics involve: From 8ba21ffa97994bf492a05948e76f126e35f5f2d7 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:26:22 -0400 Subject: [PATCH 17/34] Delete config.js --- pontoon/dashboard/static/js/config.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 pontoon/dashboard/static/js/config.js diff --git a/pontoon/dashboard/static/js/config.js b/pontoon/dashboard/static/js/config.js deleted file mode 100644 index e69de29bb2..0000000000 From 3370eb790fb15eb458457a3f43423e7088f6b33d Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:01:58 -0400 Subject: [PATCH 18/34] fix small nits, add locales param to build_chs_snapshots --- pontoon/insights/chs.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index a8b5ff85f6..f1ccf9fc36 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -92,11 +92,9 @@ def get_key_projects_enabled_by_locale( def get_contributor_metrics_by_locale(locales, end_date: datetime) -> dict[int, dict]: """ - Per-locale active-contributor counts over the 12-month window ending at end_dt. + Per-locale active-contributor counts over the 12-month window ending at end_date. """ start_date = end_date - relativedelta(months=13) - print("start date", start_date) - print("end date", end_date) managers = defaultdict(set) translators = defaultdict(set) @@ -261,11 +259,12 @@ def compute_chs(args: dict) -> float: return chs_fields -def build_chs_snapshots() -> list[LocaleHealthSnapshot]: +def build_chs_snapshots(locales=None) -> list[LocaleHealthSnapshot]: """Assemble one LocaleHealthSnapshot per visible locale for today.""" now = timezone.now() - locales = Locale.objects.visible() + if locales is None: + locales = Locale.objects.visible() completion = get_completion_by_locale(locales) enabled = get_key_projects_enabled_by_locale(locales, KEY_PROJECT_SLUGS) From 9869c0a4a19a05df64fb7a4ed79343ff2b6d27a7 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:04:48 -0400 Subject: [PATCH 19/34] add snapshot + supporting function tests --- pontoon/insights/tests/test_tasks.py | 288 +++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) 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 From c4b12d7d008e842d99093b58c5d1259994b0a62c Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:45:58 -0400 Subject: [PATCH 20/34] rename columns, rename graphs, remove CHS Y-axis --- .../templates/dashboard/widgets/locale_list.html | 16 ++++++++-------- pontoon/insights/static/js/insights_tab.js | 6 ------ .../insights/templates/insights/insights.html | 2 +- .../templates/insights/widgets/insights.html | 2 +- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/pontoon/dashboard/templates/dashboard/widgets/locale_list.html b/pontoon/dashboard/templates/dashboard/widgets/locale_list.html index 6ad62d0230..36fe520b52 100644 --- a/pontoon/dashboard/templates/dashboard/widgets/locale_list.html +++ b/pontoon/dashboard/templates/dashboard/widgets/locale_list.html @@ -3,14 +3,14 @@ Locale - M - T - C1 - C2 - N1 - EP - PC - CHS + Managers + Translators + Contr. 1 + Contr. 2 + New + Projects + Completion + Score diff --git a/pontoon/insights/static/js/insights_tab.js b/pontoon/insights/static/js/insights_tab.js index 9e5f4749f6..a7431f1868 100644 --- a/pontoon/insights/static/js/insights_tab.js +++ b/pontoon/insights/static/js/insights_tab.js @@ -1075,12 +1075,6 @@ var Pontoon = (function (my) { }, }, y: { - title: { - display: true, - text: 'CHS', - color: style.getPropertyValue('--white-1'), - fontStyle: 100, - }, beginAtZero: true, grid: { display: false, diff --git a/pontoon/insights/templates/insights/insights.html b/pontoon/insights/templates/insights/insights.html index 1b8e275a1b..b696f7e6de 100644 --- a/pontoon/insights/templates/insights/insights.html +++ b/pontoon/insights/templates/insights/insights.html @@ -18,7 +18,7 @@

- Team community health activity + Team community health score {{ Tooltip.display(intro='Community health score for each team.') }}

diff --git a/pontoon/insights/templates/insights/widgets/insights.html b/pontoon/insights/templates/insights/widgets/insights.html index 0740de16d7..dcaae1010b 100644 --- a/pontoon/insights/templates/insights/widgets/insights.html +++ b/pontoon/insights/templates/insights/widgets/insights.html @@ -149,7 +149,7 @@

- Community health activity + Community health score {{ Tooltip.display( intro='Community health score at a particular point in time.', From 49b16811288728ccdd9a0757ec7881688d956904 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:07:41 -0400 Subject: [PATCH 21/34] move /dashboard contents into /insights + renaming work --- pontoon/base/forms.py | 2 +- pontoon/dashboard/__init__.py | 0 pontoon/dashboard/static/css/dashboard.css | 103 -------- pontoon/dashboard/static/js/dashboard.js | 21 -- .../templates/dashboard/dashboard.html | 116 --------- pontoon/dashboard/urls.py | 17 -- pontoon/dashboard/views.py | 237 ------------------ .../static/css/config.css | 0 pontoon/insights/static/css/insights.css | 154 ++++++++++-- pontoon/insights/static/js/insights.js | 22 ++ .../templates/insights}/config.html | 6 +- .../insights/templates/insights/insights.html | 42 ++++ .../insights}/widgets/locale_list.html | 16 +- pontoon/insights/tests/test_views.py | 10 +- pontoon/insights/urls.py | 10 +- pontoon/insights/views.py | 188 +++++++++++++- pontoon/settings/base.py | 30 +-- pontoon/test/fixtures/base.py | 1 + pontoon/urls.py | 1 - 19 files changed, 413 insertions(+), 563 deletions(-) delete mode 100644 pontoon/dashboard/__init__.py delete mode 100644 pontoon/dashboard/static/css/dashboard.css delete mode 100644 pontoon/dashboard/static/js/dashboard.js delete mode 100644 pontoon/dashboard/templates/dashboard/dashboard.html delete mode 100644 pontoon/dashboard/urls.py delete mode 100644 pontoon/dashboard/views.py rename pontoon/{dashboard => insights}/static/css/config.css (100%) rename pontoon/{dashboard/templates/dashboard => insights/templates/insights}/config.html (88%) rename pontoon/{dashboard/templates/dashboard => insights/templates/insights}/widgets/locale_list.html (91%) diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index 04b4285d02..d6ee8e41e9 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -333,7 +333,7 @@ class Meta: fields = ("locales_order",) -class UserCommunityHealthDashboardConfigForm(forms.ModelForm): +class UserInsightsDashboardConfigForm(forms.ModelForm): """ Form is responsible for saving custom configurations of the Community Health Dashboard. """ diff --git a/pontoon/dashboard/__init__.py b/pontoon/dashboard/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pontoon/dashboard/static/css/dashboard.css b/pontoon/dashboard/static/css/dashboard.css deleted file mode 100644 index aa59e26501..0000000000 --- a/pontoon/dashboard/static/css/dashboard.css +++ /dev/null @@ -1,103 +0,0 @@ -menu { - display: flex; - .button-container { - display: flex; - margin-left: auto; - align-items: flex-end; - gap: 8px; - - #edit-config { - width: 160px; - margin-right: auto; - user-select: none; - } - - #view-toggle { - width: 120px; - margin-left: auto; - user-select: none; - } - } -} - -.locale-list { - table-layout: fixed; - width: 100%; - - &.item-list { - tbody tr:nth-child(odd) { - background: transparent; - } - tbody tr:nth-child(even) { - background: var(--dark-grey-1); - } - } - - &.show-score-view { - .info-container.base-view { - display: none; - } - .info-container.score-view { - display: flex; - } - } - - .info-container { - display: flex; - flex-direction: column; - - .info { - align-self: center; - - &.met { - color: var(--status-translated); - font-weight: bold; - } - } - - &.score-view { - display: none; - } - - .prev { - align-self: flex-end; - display: flex; - align-items: center; - gap: 4px; - font-size: 12px; - user-select: none; - justify-content: center; - min-width: 36px; - height: 20px; - - &.up { - color: var(--status-translated); - } - - &.down { - color: var(--status-error); - } - - &.equal { - color: var(--grey-1); - justify-content: center; - } - - .fa-caret-up, - .fa-caret-down { - font-size: 1.4em; - } - } - } - - .no-snapshot { - color: var(--translation-secondary-color); - font-style: italic; - } -} - -.no-locales { - color: var(--light-grey-7); - font-style: italic; - padding: 10px 16px; -} diff --git a/pontoon/dashboard/static/js/dashboard.js b/pontoon/dashboard/static/js/dashboard.js deleted file mode 100644 index 3f159f4148..0000000000 --- a/pontoon/dashboard/static/js/dashboard.js +++ /dev/null @@ -1,21 +0,0 @@ -$('body').on('click', '#view-toggle', function (e) { - e.stopPropagation(); - - const table = $('.locale-list'); - table.toggleClass('show-score-view'); - - const showScores = table.hasClass('show-score-view'); - - // Keep each cells sort key in sync - table.find('td.cell').each(function () { - const td = $(this); - const key = showScores - ? td.attr('data-score-sort') - : td.attr('data-base-sort'); - if (key !== undefined) { - td.attr('data-sort', key); - } - }); - - $('#view-toggle').text(showScores ? 'Show default' : 'Show scores'); -}); diff --git a/pontoon/dashboard/templates/dashboard/dashboard.html b/pontoon/dashboard/templates/dashboard/dashboard.html deleted file mode 100644 index 02cbb1cddd..0000000000 --- a/pontoon/dashboard/templates/dashboard/dashboard.html +++ /dev/null @@ -1,116 +0,0 @@ -{% extends "base.html" %} -{% import 'widgets/heading_info.html' as HeadingInfo %} -{% import 'dashboard/widgets/locale_list.html' as LocaleList %} - -{% block title %}Community Health Dashboard{% endblock %} - -{% block class %}teams{% endblock %} - -{% block heading %} -
-
-

- {{ - HeadingInfo.heading_item( - title='Hey, ' ~ user.name_or_email ~ '!', - link=url('pontoon.dashboard')) - }} -

- -
    - {{ - HeadingInfo.details_item( - title='Number of teams', - class='teams-count', - value=locales|length) - }} - {% if top_instances %} - {{ - HeadingInfo.details_item( - title='Most translations', - class='most-translations', - value=top_instances['most_translations'], - value_link=url('pontoon.teams.team', top_instances['most_translations'].code)) - }} - {{ - HeadingInfo.details_item( - title='Most unreviewed', - class='most-suggestions', - value=top_instances['most_suggestions'], - value_link=url('pontoon.teams.team', top_instances['most_suggestions'].code)) - }} - {{ - HeadingInfo.details_item( - title='Most missing strings', - class='missing-strings', - value=top_instances['most_missing'], - value_link=url('pontoon.teams.team', top_instances['most_missing'].code)) - }} - {{ - HeadingInfo.details_item( - title='Most enabled strings', - class='most-strings', - value=top_instances['most_strings'], - value_link=url('pontoon.teams.team', top_instances['most_strings'].code)) - }} - {% endif %} -
- - {{ HeadingInfo.progress_chart() }} - {{ HeadingInfo.progress_chart_legend(all_locales_stats) }} -
-
-{% endblock %} - -{% block bottom %} -
-
- -
- - -
-
- Edit Configuration - - -
-
- - {{ LocaleList.header() }} - - {% for locale in locales %} - {% set main_link = url('pontoon.teams.team', locale.code) %} - - {{ LocaleList.item(locale, main_link, curr_snapshot=current_snapshots.get(locale.id), base_deltas=snapshot_base_deltas.get(locale.id), score_deltas=snapshot_score_deltas.get(locale.id), columns=columns) }} - {% endfor %} - - {{ LocaleList.footer(request=True, form=form) }} - - {% if not locales %} -
-

No locales selected.

-
- {% endif %} -
-
-{% endblock %} - -{% block extend_css %} - {% stylesheet 'dashboard' %} -{% endblock %} - -{% block extend_js %} - {% javascript 'dashboard' %} -{% endblock %} diff --git a/pontoon/dashboard/urls.py b/pontoon/dashboard/urls.py deleted file mode 100644 index e22e92708a..0000000000 --- a/pontoon/dashboard/urls.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.urls import path - -from pontoon.dashboard.views import dashboard, dashboard_config - - -urlpatterns = [ - path( - "dashboard/", - dashboard, - name="pontoon.dashboard", - ), - path( - "dashboard/config", - dashboard_config, - name="pontoon.dashboard.config", - ), -] diff --git a/pontoon/dashboard/views.py b/pontoon/dashboard/views.py deleted file mode 100644 index 24a0d686c6..0000000000 --- a/pontoon/dashboard/views.py +++ /dev/null @@ -1,237 +0,0 @@ -from dateutil.relativedelta import relativedelta - -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.core.exceptions import PermissionDenied -from django.shortcuts import redirect, render -from django.utils import timezone - -from pontoon.base.aggregated_stats import get_top_instances -from pontoon.base.forms import UserCommunityHealthDashboardConfigForm -from pontoon.base.models.locale import Locale -from pontoon.base.models.translated_resource import TranslatedResource -from pontoon.insights.chs import KEY_PROJECT_SLUGS -from pontoon.insights.models import LocaleHealthSnapshot -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, -) -from pontoon.teams.forms import LocaleRequestForm - - -LOCALES = [ - # Top 15 (+es-CL) - "cs", - "de", - "es-AR", - "es-CL", - "es-ES", - "es-MX", - "fr", - "hu", - "id", - "it", - "ja", - "nl", - "pl", - "pt-BR", - "ru", - "zh-CN", - # Top 25 - "tr", - "el", - "zh-TW", - "fi", - "pt-PT", - "sv-SE", - "vi", - "sk", - "ar", - # Romanian - "ro", -] - -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, month): - month_start = month.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 dashboard_config(request): - """Configure which locales appear on the CHS dashboard.""" - user = request.user - profile = user.profile - - if not user.is_staff: - raise PermissionDenied - - if request.method == "POST": - dashboard_locales_form = UserCommunityHealthDashboardConfigForm( - request.POST, instance=profile - ) - - if dashboard_locales_form.is_valid(): - dashboard_locales_form.save() - messages.success(request, "Configuration saved.") - return redirect("pontoon.dashboard") - - 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, - "dashboard/config.html", - { - "available_locales": available_locales, - "selected_locales": selected_locales, - }, - ) - - -@login_required(redirect_field_name="", login_url="/403") -def dashboard(request): - """List all localization team CHS scores.""" - - user = request.user - profile = user.profile - - if not user.is_staff: - raise PermissionDenied - - # TODO Include "Last Updated -> Next scheduled update timer" so PMs don't get confused - # TODO Maybe also include a "Run failed" warning or the option to manually run CHS calculation - dashboard_locales = profile.dashboard_locales - locales = Locale.objects.visible().filter(pk__in=dashboard_locales).order_by("code") - - # TEMP: the most recent snapshot is from May, so shift the window back one - # month — render the previous month as "current" and the month before as - # "previous". Revert `current_anchor` to `timezone.now().date()` once the - # current month has snapshots. - current_anchor = timezone.now().date().replace(day=1) - relativedelta(days=1) - 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 - ) - - form = LocaleRequestForm() - - locale_stats = locales.stats_data_as_dict() - print(snapshot_base_deltas) - print(snapshot_score_deltas) - return render( - request, - "dashboard/dashboard.html", - { - "locales": locales, - "current_snapshots": current_snapshots, - "snapshot_base_deltas": snapshot_base_deltas, - "snapshot_score_deltas": snapshot_score_deltas, - "columns": CHS_COLUMNS, - "all_locales_stats": TranslatedResource.objects.string_stats(), - "locale_stats": locale_stats, - "form": form, - "top_instances": get_top_instances(locales, locale_stats), - }, - ) diff --git a/pontoon/dashboard/static/css/config.css b/pontoon/insights/static/css/config.css similarity index 100% rename from pontoon/dashboard/static/css/config.css rename to pontoon/insights/static/css/config.css diff --git a/pontoon/insights/static/css/insights.css b/pontoon/insights/static/css/insights.css index e1c4127c49..232568a716 100644 --- a/pontoon/insights/static/css/insights.css +++ b/pontoon/insights/static/css/insights.css @@ -1,28 +1,138 @@ -#insights .block { - margin-bottom: 40px; -} +#insights { + menu { + display: flex; + .button-container { + display: flex; + margin-left: auto; + align-items: flex-end; + gap: 8px; -#insights .block .chart-wrapper { - float: right; - width: 680px; -} + #edit-config { + width: 160px; + margin-right: auto; + user-select: none; + } -/* Custom chart legend */ + #view-toggle { + width: 120px; + margin-left: auto; + user-select: none; + } + } + } -#insights .legend { - float: left; - text-align: left; - width: 240px; -} + .dashboard-container { + min-height: 600px; + margin-bottom: 40px; + } -#insights .legend ul { - height: 330px; - margin: 0; - overflow-y: auto; -} + .locale-list { + table-layout: fixed; + width: 100%; + + &.item-list { + tbody tr:nth-child(odd) { + background: transparent; + } + tbody tr:nth-child(even) { + background: var(--dark-grey-1); + } + } + + &.show-score-view { + .info-container.base-view { + display: none; + } + .info-container.score-view { + display: flex; + } + } + + .info-container { + display: flex; + flex-direction: column; + + .info { + align-self: center; + + &.met { + color: var(--status-translated); + font-weight: bold; + } + } + + &.score-view { + display: none; + } + + .prev { + align-self: flex-end; + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + user-select: none; + justify-content: center; + min-width: 36px; + height: 20px; + + &.up { + color: var(--status-translated); + } + + &.down { + color: var(--status-error); + } + + &.equal { + color: var(--grey-1); + justify-content: center; + } + + .fa-caret-up, + .fa-caret-down { + font-size: 1.4em; + } + } + } + + .no-snapshot { + color: var(--translation-secondary-color); + font-style: italic; + } + } + + .no-locales { + color: var(--light-grey-7); + font-style: italic; + padding: 10px 16px; + } + + .block { + margin-bottom: 40px; + + .chart-wrapper { + float: right; + width: 680px; + } + } + + /* Custom chart legend */ + .legend { + float: left; + text-align: left; + width: 240px; + + ul { + height: 330px; + margin: 0; + overflow-y: auto; + } -#insights .legend li { - display: block; - margin: 0; - margin-bottom: 5px; + li { + display: block; + margin: 0; + margin-bottom: 5px; + } + } } diff --git a/pontoon/insights/static/js/insights.js b/pontoon/insights/static/js/insights.js index b6684d1ba7..a0a42bc545 100644 --- a/pontoon/insights/static/js/insights.js +++ b/pontoon/insights/static/js/insights.js @@ -9,6 +9,28 @@ const longMonthFormat = new Intl.DateTimeFormat('en', { const style = getComputedStyle(document.body); +$('body').on('click', '#view-toggle', function (e) { + e.stopPropagation(); + + const table = $('.locale-list'); + table.toggleClass('show-score-view'); + + const showScores = table.hasClass('show-score-view'); + + // Keep each cells sort key in sync + table.find('td.cell').each(function () { + const td = $(this); + const key = showScores + ? td.attr('data-score-sort') + : td.attr('data-base-sort'); + if (key !== undefined) { + td.attr('data-sort', key); + } + }); + + $('#view-toggle').text(showScores ? 'Show default' : 'Show scores'); +}); + // eslint-disable-next-line no-var var Pontoon = (function (my) { return $.extend(true, my, { diff --git a/pontoon/dashboard/templates/dashboard/config.html b/pontoon/insights/templates/insights/config.html similarity index 88% rename from pontoon/dashboard/templates/dashboard/config.html rename to pontoon/insights/templates/insights/config.html index df66f899bb..f88426ba6f 100644 --- a/pontoon/dashboard/templates/dashboard/config.html +++ b/pontoon/insights/templates/insights/config.html @@ -33,15 +33,15 @@

- Cancel + Cancel
{% endblock %} {% block extend_css %} - {% stylesheet 'dashboard' %} + {% stylesheet 'insights' %} {% endblock %} {% block extend_js %} - {% javascript 'dashboard' %} + {% javascript 'insights' %} {% endblock %} diff --git a/pontoon/insights/templates/insights/insights.html b/pontoon/insights/templates/insights/insights.html index b696f7e6de..f44162f044 100644 --- a/pontoon/insights/templates/insights/insights.html +++ b/pontoon/insights/templates/insights/insights.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% import 'heading.html' as Heading %} {% import "insights/widgets/tooltip.html" as Tooltip %} +{% import 'insights/widgets/locale_list.html' as LocaleList %} {% block title %}Insights{% endblock %} @@ -14,6 +15,47 @@
+ +
+ + +
+
+ Edit Configuration + + +
+
+ +
+ {{ LocaleList.header() }} + + {% for locale in locales %} + {% set main_link = url('pontoon.teams.team', locale.code) %} + + {{ LocaleList.item(locale, main_link, curr_snapshot=current_snapshots.get(locale.id), base_deltas=snapshot_base_deltas.get(locale.id), score_deltas=snapshot_score_deltas.get(locale.id), columns=columns) }} + {% endfor %} + + {{ LocaleList.footer() }} + + {% if not locales %} +
+

No locales selected.

+
+ {% endif %} +
+ {% if global_locale_health_insights %}
diff --git a/pontoon/dashboard/templates/dashboard/widgets/locale_list.html b/pontoon/insights/templates/insights/widgets/locale_list.html similarity index 91% rename from pontoon/dashboard/templates/dashboard/widgets/locale_list.html rename to pontoon/insights/templates/insights/widgets/locale_list.html index 36fe520b52..98c9a4f3f0 100644 --- a/pontoon/dashboard/templates/dashboard/widgets/locale_list.html +++ b/pontoon/insights/templates/insights/widgets/locale_list.html @@ -1,14 +1,20 @@ -{% macro header(request=False) %} +{% macro header() %} - - + + - + @@ -91,7 +97,7 @@ {% endmacro %} -{% macro footer(request=False, form=None) %} +{% macro footer() %} diff --git a/pontoon/insights/tests/test_views.py b/pontoon/insights/tests/test_views.py index b262c2fbc9..687cc16c5b 100644 --- a/pontoon/insights/tests/test_views.py +++ b/pontoon/insights/tests/test_views.py @@ -43,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] @@ -96,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), @@ -133,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] 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/views.py b/pontoon/insights/views.py index 4c6ee9a118..603a097b3d 100644 --- a/pontoon/insights/views.py +++ b/pontoon/insights/views.py @@ -3,26 +3,198 @@ 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.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, month): + month_start = month.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 CHS 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") + + # TEMP: the most recent snapshot is from May, so shift the window back one + # month — render the previous month as "current" and the month before as + # "previous". Revert `current_anchor` to `timezone.now().date()` once the + # current month has snapshots. + current_anchor = timezone.now().date().replace(day=1) - relativedelta(days=1) + 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. @@ -45,12 +217,7 @@ def insights(request): project_pt_key, project_pretranslation_quality, settings.VIEW_CACHE_TIMEOUT ) - global_locale_health_insights = [] - user = request.user - if user.is_staff: - locale_ids = user.profile.dashboard_locales - locales = Locale.objects.filter(pk__in=locale_ids) - global_locale_health_insights = get_global_locale_health_insights(locales) + global_locale_health_insights = get_global_locale_health_insights(locales) return render( request, @@ -58,6 +225,11 @@ def insights(request): { "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 568cc0f6f5..97c169b84c 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -236,7 +236,6 @@ def _default_from_email(): "pontoon.base", "pontoon.contributors", "pontoon.checks", - "pontoon.dashboard", "pontoon.insights", "pontoon.localizations", "pontoon.machinery", @@ -461,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", }, @@ -509,17 +513,6 @@ def _default_from_email(): ), "output_filename": "css/teams.min.css", }, - "dashboard": { - "source_filenames": ( - "css/heading_info.css", - "css/table.css", - "css/request.css", - "css/multiple_item_selector.css", - "css/dashboard.css", - "css/config.css", - ), - "output_filename": "css/dashboard.min.css", - }, "sync_log": { "source_filenames": ( "css/table.css", @@ -631,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", ), @@ -698,17 +693,6 @@ def _default_from_email(): ), "output_filename": "js/teams.min.js", }, - "dashboard": { - "source_filenames": ( - "js/table.js", - "js/progress-chart.js", - "js/request.js", - "js/multiple_item_selector.js", - "js/dashboard.js", - "js/config.js", - ), - "output_filename": "js/dashboard.min.js", - }, "sync_log": { "source_filenames": ( "js/sync_log.js", 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/urls.py b/pontoon/urls.py index 121edef3cc..3197fa38d1 100644 --- a/pontoon/urls.py +++ b/pontoon/urls.py @@ -79,7 +79,6 @@ def docs_serve(request, path="index.html"): path("", include("pontoon.tour.urls")), path("", include("pontoon.tags.urls")), path("", include("pontoon.search.urls")), - path("", include("pontoon.dashboard.urls")), path("", include("pontoon.sync.urls")), path("", include("pontoon.projects.urls")), path("", include("pontoon.machinery.urls")), From c4ce0147478cc75875dae8325511ad4e60ac097b Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:10:32 -0400 Subject: [PATCH 22/34] renaming dashboards --- pontoon/base/forms.py | 2 +- pontoon/insights/templates/insights/config.html | 2 +- pontoon/insights/views.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index d6ee8e41e9..1bdc84a4ad 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -335,7 +335,7 @@ class Meta: class UserInsightsDashboardConfigForm(forms.ModelForm): """ - Form is responsible for saving custom configurations of the Community Health Dashboard. + Form is responsible for saving custom configurations of the Insights Dashboard. """ class Meta: diff --git a/pontoon/insights/templates/insights/config.html b/pontoon/insights/templates/insights/config.html index f88426ba6f..1bf9e54c58 100644 --- a/pontoon/insights/templates/insights/config.html +++ b/pontoon/insights/templates/insights/config.html @@ -12,7 +12,7 @@ action="{{ request.path }}" class="{% if pk %}edit{% else %}add{% endif %}" > -

Dashboard Configuration

+

Insights Dashboard Configuration

{% csrf_token %} diff --git a/pontoon/insights/views.py b/pontoon/insights/views.py index 603a097b3d..c8f96920bd 100644 --- a/pontoon/insights/views.py +++ b/pontoon/insights/views.py @@ -129,7 +129,7 @@ def get_monthly_snapshot_deltas(current_snapshots, previous_snapshots, metrics): @login_required(redirect_field_name="", login_url="/403") def insights_config(request): - """Configure which locales appear on the CHS dashboard.""" + """Configure which locales appear on the Insights dashboard.""" if not settings.ENABLE_INSIGHTS: raise ImproperlyConfigured("ENABLE_INSIGHTS variable not set in settings.") From 836bd413ba9e36f45b1409781e69e839a39c7c4f Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:39:18 -0400 Subject: [PATCH 23/34] doc changes --- documentation/docs/dev/deployment.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/docs/dev/deployment.md b/documentation/docs/dev/deployment.md index 2709c5a963..dd1199cc91 100644 --- a/documentation/docs/dev/deployment.md +++ b/documentation/docs/dev/deployment.md @@ -498,9 +498,9 @@ the day, every day. Captures per-locale Contributor Health Score metrics - completion, key-project enablement, active managers / translators / contributors & new signups into -`LocaleHealthSnapshot` model. Used by the CHS dashboard for month-over-month -comparisons and by the Insights pages for monthly trend charts. The job is -designed to run once a month on the first of each month. +`LocaleHealthSnapshot` model. Used via the Insights dashboard and pages for +month-over-month CHS comparisons and monthly trend charts. The job is designed +to run once a month on the first of each month. ``` bash ./manage.py collect_chs_snapshots From e2f48026dc0ce2cfa1fb8f83cf44611565c264a3 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:51:07 -0400 Subject: [PATCH 24/34] Create 0118_merge_20260624_0350.py --- .../base/migrations/0118_merge_20260624_0350.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 pontoon/base/migrations/0118_merge_20260624_0350.py diff --git a/pontoon/base/migrations/0118_merge_20260624_0350.py b/pontoon/base/migrations/0118_merge_20260624_0350.py new file mode 100644 index 0000000000..747dcb7598 --- /dev/null +++ b/pontoon/base/migrations/0118_merge_20260624_0350.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.14 on 2026-06-24 03:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0117_userprofile_dashboard_locales'), + ('base', '0117_userprofile_editor_theme'), + ] + + operations = [ + ] From e0eade83487c61e6c9e55c4de5f6499739f76493 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:52:09 -0400 Subject: [PATCH 25/34] run make format --- pontoon/base/migrations/0118_merge_20260624_0350.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pontoon/base/migrations/0118_merge_20260624_0350.py b/pontoon/base/migrations/0118_merge_20260624_0350.py index 747dcb7598..3e7bb6c7bb 100644 --- a/pontoon/base/migrations/0118_merge_20260624_0350.py +++ b/pontoon/base/migrations/0118_merge_20260624_0350.py @@ -4,11 +4,9 @@ class Migration(migrations.Migration): - dependencies = [ - ('base', '0117_userprofile_dashboard_locales'), - ('base', '0117_userprofile_editor_theme'), + ("base", "0117_userprofile_dashboard_locales"), + ("base", "0117_userprofile_editor_theme"), ] - operations = [ - ] + operations = [] From 3be5b908ac13db734c3c96cf8ea1f2fedc7e45ce Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:19:03 -0400 Subject: [PATCH 26/34] revert temp stopgap --- pontoon/insights/views.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pontoon/insights/views.py b/pontoon/insights/views.py index c8f96920bd..b79fbb6a8c 100644 --- a/pontoon/insights/views.py +++ b/pontoon/insights/views.py @@ -93,8 +93,8 @@ } -def get_monthly_snapshots(locales, month): - month_start = month.replace(day=1) +def get_monthly_snapshots(locales, date): + month_start = date.replace(day=1) next_month_start = month_start + relativedelta(months=1) snapshots = LocaleHealthSnapshot.objects.filter( @@ -180,11 +180,7 @@ def insights(request): dashboard_locales = profile.dashboard_locales locales = Locale.objects.filter(pk__in=dashboard_locales).order_by("code") - # TEMP: the most recent snapshot is from May, so shift the window back one - # month — render the previous month as "current" and the month before as - # "previous". Revert `current_anchor` to `timezone.now().date()` once the - # current month has snapshots. - current_anchor = timezone.now().date().replace(day=1) - relativedelta(days=1) + 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) From 6b12b9afb35b859b6f8305bd18eede32766bd5f6 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:39:07 -0400 Subject: [PATCH 27/34] streamline LocaleHealthSnapshot migrations --- .../migrations/0021_localechssnapshot.py | 56 ---------------- .../migrations/0021_localehealthsnapshot.py | 42 ++++++++++++ ...tive_contributors_200_approved_and_more.py | 21 ------ ...pshot_delete_localechssnapshot_and_more.py | 67 ------------------- ...score_localehealthsnapshot_chs_and_more.py | 47 ------------- ...shot_active_contributors_score_and_more.py | 57 ---------------- 6 files changed, 42 insertions(+), 248 deletions(-) delete mode 100644 pontoon/insights/migrations/0021_localechssnapshot.py create mode 100644 pontoon/insights/migrations/0021_localehealthsnapshot.py delete mode 100644 pontoon/insights/migrations/0022_remove_localechssnapshot_active_contributors_200_approved_and_more.py delete mode 100644 pontoon/insights/migrations/0023_localehealthsnapshot_delete_localechssnapshot_and_more.py delete mode 100644 pontoon/insights/migrations/0024_rename_chs_score_localehealthsnapshot_chs_and_more.py delete mode 100644 pontoon/insights/migrations/0025_alter_localehealthsnapshot_active_contributors_score_and_more.py diff --git a/pontoon/insights/migrations/0021_localechssnapshot.py b/pontoon/insights/migrations/0021_localechssnapshot.py deleted file mode 100644 index 7ee0be28f2..0000000000 --- a/pontoon/insights/migrations/0021_localechssnapshot.py +++ /dev/null @@ -1,56 +0,0 @@ -# Generated by Django 5.2.14 on 2026-06-02 19:59 - -import django.db.models.deletion - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("base", "0113_userbanlog"), - ("insights", "0020_fix_pretranslations_chrf_score"), - ] - - operations = [ - migrations.CreateModel( - name="LocaleChsSnapshot", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateField()), - ("completion", models.FloatField(default=0.0)), - ("key_projects_enabled", models.PositiveIntegerField(default=0)), - ("active_managers", models.PositiveIntegerField(default=0)), - ("active_translators", models.PositiveIntegerField(default=0)), - ("active_contributors", models.PositiveIntegerField(default=0)), - ( - "active_contributors_200_approved", - models.PositiveIntegerField(default=0), - ), - ("new_signups", models.PositiveIntegerField(default=0)), - ("chs_score", models.FloatField(default=0.0)), - ( - "locale", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="base.locale" - ), - ), - ], - options={ - "indexes": [ - models.Index( - fields=["created_at", "locale"], - name="insights_lo_created_eb85fa_idx", - ) - ], - "unique_together": {("locale", "created_at")}, - }, - ), - ] diff --git a/pontoon/insights/migrations/0021_localehealthsnapshot.py b/pontoon/insights/migrations/0021_localehealthsnapshot.py new file mode 100644 index 0000000000..278bfca94e --- /dev/null +++ b/pontoon/insights/migrations/0021_localehealthsnapshot.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.14 on 2026-06-24 04:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0118_merge_20260624_0350'), + ('insights', '0020_fix_pretranslations_chrf_score'), + ] + + operations = [ + migrations.CreateModel( + name='LocaleHealthSnapshot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateField()), + ('completion', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('key_projects_enabled', models.PositiveIntegerField(default=0)), + ('active_managers', models.PositiveIntegerField(default=0)), + ('active_translators', models.PositiveIntegerField(default=0)), + ('active_contributors', models.PositiveIntegerField(default=0)), + ('all_contributors', models.PositiveIntegerField(default=0)), + ('new_signups', models.PositiveIntegerField(default=0)), + ('completion_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('key_projects_enabled_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('active_managers_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('active_translators_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('active_contributors_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('all_contributors_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('new_signups_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('chs', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('locale', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.locale')), + ], + options={ + 'indexes': [models.Index(fields=['created_at', 'locale'], name='insights_lo_created_b801b7_idx')], + 'unique_together': {('locale', 'created_at')}, + }, + ), + ] diff --git a/pontoon/insights/migrations/0022_remove_localechssnapshot_active_contributors_200_approved_and_more.py b/pontoon/insights/migrations/0022_remove_localechssnapshot_active_contributors_200_approved_and_more.py deleted file mode 100644 index f58d784203..0000000000 --- a/pontoon/insights/migrations/0022_remove_localechssnapshot_active_contributors_200_approved_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.2.14 on 2026-06-09 17:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("insights", "0021_localechssnapshot"), - ] - - operations = [ - migrations.RemoveField( - model_name="localechssnapshot", - name="active_contributors_200_approved", - ), - migrations.AddField( - model_name="localechssnapshot", - name="all_contributors", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/pontoon/insights/migrations/0023_localehealthsnapshot_delete_localechssnapshot_and_more.py b/pontoon/insights/migrations/0023_localehealthsnapshot_delete_localechssnapshot_and_more.py deleted file mode 100644 index d53ec154f1..0000000000 --- a/pontoon/insights/migrations/0023_localehealthsnapshot_delete_localechssnapshot_and_more.py +++ /dev/null @@ -1,67 +0,0 @@ -# Generated by Django 5.2.14 on 2026-06-09 19:14 - -import django.db.models.deletion - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("base", "0113_userbanlog"), - ( - "insights", - "0022_remove_localechssnapshot_active_contributors_200_approved_and_more", - ), - ] - - operations = [ - migrations.CreateModel( - name="LocaleHealthSnapshot", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateField()), - ("completion", models.FloatField(default=0.0)), - ("key_projects_enabled", models.PositiveIntegerField(default=0)), - ("active_managers", models.PositiveIntegerField(default=0)), - ("active_translators", models.PositiveIntegerField(default=0)), - ("active_contributors", models.PositiveIntegerField(default=0)), - ("all_contributors", models.PositiveIntegerField(default=0)), - ("new_signups", models.PositiveIntegerField(default=0)), - ("completion_score", models.FloatField(default=0.0)), - ("key_projects_enabled_score", models.PositiveIntegerField(default=0)), - ("active_managers_score", models.PositiveIntegerField(default=0)), - ("active_translators_score", models.PositiveIntegerField(default=0)), - ("active_contributors_score", models.PositiveIntegerField(default=0)), - ("all_contributors_score", models.PositiveIntegerField(default=0)), - ("new_signups_score", models.PositiveIntegerField(default=0)), - ("chs_score", models.FloatField(default=0.0)), - ( - "locale", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="base.locale" - ), - ), - ], - ), - migrations.DeleteModel( - name="LocaleChsSnapshot", - ), - migrations.AddIndex( - model_name="localehealthsnapshot", - index=models.Index( - fields=["created_at", "locale"], name="insights_lo_created_b801b7_idx" - ), - ), - migrations.AlterUniqueTogether( - name="localehealthsnapshot", - unique_together={("locale", "created_at")}, - ), - ] diff --git a/pontoon/insights/migrations/0024_rename_chs_score_localehealthsnapshot_chs_and_more.py b/pontoon/insights/migrations/0024_rename_chs_score_localehealthsnapshot_chs_and_more.py deleted file mode 100644 index 3fac542220..0000000000 --- a/pontoon/insights/migrations/0024_rename_chs_score_localehealthsnapshot_chs_and_more.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 5.2.14 on 2026-06-09 19:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("insights", "0023_localehealthsnapshot_delete_localechssnapshot_and_more"), - ] - - operations = [ - migrations.RenameField( - model_name="localehealthsnapshot", - old_name="chs_score", - new_name="chs", - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="active_contributors_score", - field=models.FloatField(default=0), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="active_managers_score", - field=models.FloatField(default=0), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="active_translators_score", - field=models.FloatField(default=0), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="all_contributors_score", - field=models.FloatField(default=0), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="key_projects_enabled_score", - field=models.FloatField(default=0), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="new_signups_score", - field=models.FloatField(default=0), - ), - ] diff --git a/pontoon/insights/migrations/0025_alter_localehealthsnapshot_active_contributors_score_and_more.py b/pontoon/insights/migrations/0025_alter_localehealthsnapshot_active_contributors_score_and_more.py deleted file mode 100644 index 64edee97b8..0000000000 --- a/pontoon/insights/migrations/0025_alter_localehealthsnapshot_active_contributors_score_and_more.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 5.2.14 on 2026-06-12 19:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("insights", "0024_rename_chs_score_localehealthsnapshot_chs_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="localehealthsnapshot", - name="active_contributors_score", - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="active_managers_score", - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="active_translators_score", - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="all_contributors_score", - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="chs", - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="completion", - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="completion_score", - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="key_projects_enabled_score", - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - migrations.AlterField( - model_name="localehealthsnapshot", - name="new_signups_score", - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - ] From 898c2e731cd62de01975527bce776eebc2b636c3 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:39:44 -0400 Subject: [PATCH 28/34] run make format --- .../migrations/0021_localehealthsnapshot.py | 90 ++++++++++++++----- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/pontoon/insights/migrations/0021_localehealthsnapshot.py b/pontoon/insights/migrations/0021_localehealthsnapshot.py index 278bfca94e..30e80141d2 100644 --- a/pontoon/insights/migrations/0021_localehealthsnapshot.py +++ b/pontoon/insights/migrations/0021_localehealthsnapshot.py @@ -1,42 +1,84 @@ # Generated by Django 5.2.14 on 2026-06-24 04:38 import django.db.models.deletion + from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('base', '0118_merge_20260624_0350'), - ('insights', '0020_fix_pretranslations_chrf_score'), + ("base", "0118_merge_20260624_0350"), + ("insights", "0020_fix_pretranslations_chrf_score"), ] operations = [ migrations.CreateModel( - name='LocaleHealthSnapshot', + name="LocaleHealthSnapshot", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateField()), - ('completion', models.DecimalField(decimal_places=2, default=0, max_digits=5)), - ('key_projects_enabled', models.PositiveIntegerField(default=0)), - ('active_managers', models.PositiveIntegerField(default=0)), - ('active_translators', models.PositiveIntegerField(default=0)), - ('active_contributors', models.PositiveIntegerField(default=0)), - ('all_contributors', models.PositiveIntegerField(default=0)), - ('new_signups', models.PositiveIntegerField(default=0)), - ('completion_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), - ('key_projects_enabled_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), - ('active_managers_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), - ('active_translators_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), - ('active_contributors_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), - ('all_contributors_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), - ('new_signups_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), - ('chs', models.DecimalField(decimal_places=2, default=0, max_digits=5)), - ('locale', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.locale')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateField()), + ( + "completion", + models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + ("key_projects_enabled", models.PositiveIntegerField(default=0)), + ("active_managers", models.PositiveIntegerField(default=0)), + ("active_translators", models.PositiveIntegerField(default=0)), + ("active_contributors", models.PositiveIntegerField(default=0)), + ("all_contributors", models.PositiveIntegerField(default=0)), + ("new_signups", models.PositiveIntegerField(default=0)), + ( + "completion_score", + models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + ( + "key_projects_enabled_score", + models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + ( + "active_managers_score", + models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + ( + "active_translators_score", + models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + ( + "active_contributors_score", + models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + ( + "all_contributors_score", + models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + ( + "new_signups_score", + models.DecimalField(decimal_places=2, default=0, max_digits=5), + ), + ("chs", models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ( + "locale", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="base.locale" + ), + ), ], options={ - 'indexes': [models.Index(fields=['created_at', 'locale'], name='insights_lo_created_b801b7_idx')], - 'unique_together': {('locale', 'created_at')}, + "indexes": [ + models.Index( + fields=["created_at", "locale"], + name="insights_lo_created_b801b7_idx", + ) + ], + "unique_together": {("locale", "created_at")}, }, ), ] From 845b226d2e895d67de191f221a5d52f412da52b5 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:36:27 -0400 Subject: [PATCH 29/34] merge migration --- .../base/migrations/0119_merge_20260624_1835.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 pontoon/base/migrations/0119_merge_20260624_1835.py diff --git a/pontoon/base/migrations/0119_merge_20260624_1835.py b/pontoon/base/migrations/0119_merge_20260624_1835.py new file mode 100644 index 0000000000..1fff9b6999 --- /dev/null +++ b/pontoon/base/migrations/0119_merge_20260624_1835.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.14 on 2026-06-24 18:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0118_fix_terminology_entity_value'), + ('base', '0118_merge_20260624_0350'), + ] + + operations = [ + ] From bf7a7a3a3a44988fe0924746a9dc12d328c8ed25 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:38:07 -0400 Subject: [PATCH 30/34] run make format --- pontoon/base/migrations/0119_merge_20260624_1835.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pontoon/base/migrations/0119_merge_20260624_1835.py b/pontoon/base/migrations/0119_merge_20260624_1835.py index 1fff9b6999..10c4d8a7e3 100644 --- a/pontoon/base/migrations/0119_merge_20260624_1835.py +++ b/pontoon/base/migrations/0119_merge_20260624_1835.py @@ -4,11 +4,9 @@ class Migration(migrations.Migration): - dependencies = [ - ('base', '0118_fix_terminology_entity_value'), - ('base', '0118_merge_20260624_0350'), + ("base", "0118_fix_terminology_entity_value"), + ("base", "0118_merge_20260624_0350"), ] - operations = [ - ] + operations = [] From 7f6dc85527ddaebbd122c1532bba852e55a7127c Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:57:48 -0400 Subject: [PATCH 31/34] rename collect_chs_snapshot file, center Locale, show All in graphs --- .../{collect_chs_snapshot.py => collect_chs_snapshots.py} | 0 pontoon/insights/static/css/insights.css | 4 ++++ pontoon/insights/static/js/insights.js | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) rename pontoon/insights/management/commands/{collect_chs_snapshot.py => collect_chs_snapshots.py} (100%) diff --git a/pontoon/insights/management/commands/collect_chs_snapshot.py b/pontoon/insights/management/commands/collect_chs_snapshots.py similarity index 100% rename from pontoon/insights/management/commands/collect_chs_snapshot.py rename to pontoon/insights/management/commands/collect_chs_snapshots.py diff --git a/pontoon/insights/static/css/insights.css b/pontoon/insights/static/css/insights.css index 232568a716..4d7cf18e47 100644 --- a/pontoon/insights/static/css/insights.css +++ b/pontoon/insights/static/css/insights.css @@ -30,6 +30,10 @@ table-layout: fixed; width: 100%; + th:first-child { + text-align: center; + } + &.item-list { tbody tr:nth-child(odd) { background: transparent; diff --git a/pontoon/insights/static/js/insights.js b/pontoon/insights/static/js/insights.js index a0a42bc545..b7dfeaa708 100644 --- a/pontoon/insights/static/js/insights.js +++ b/pontoon/insights/static/js/insights.js @@ -88,7 +88,7 @@ var Pontoon = (function (my) { fill: true, tension: 0.4, order: color.length - index, - hidden: true, + hidden: item.name === 'All' ? false : true, }; }); From 6c7c17a8d830c3414a01607743071925079a8110 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:35:33 -0400 Subject: [PATCH 32/34] push All to top of global charts --- pontoon/insights/static/js/insights.js | 6 ++++-- pontoon/insights/tests/test_views.py | 8 ++++---- pontoon/insights/utils.py | 17 ++++++++++------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/pontoon/insights/static/js/insights.js b/pontoon/insights/static/js/insights.js index b7dfeaa708..cb4afc0eba 100644 --- a/pontoon/insights/static/js/insights.js +++ b/pontoon/insights/static/js/insights.js @@ -55,7 +55,6 @@ var Pontoon = (function (my) { } const colors = [ - style.getPropertyValue('--white-1'), style.getPropertyValue('--purple'), style.getPropertyValue('--lilac'), style.getPropertyValue('--pink-2'), @@ -71,7 +70,10 @@ var Pontoon = (function (my) { ]; const datasets = chart.data('dataset').map(function (item, index) { - const color = colors[index % colors.length]; + const color = + item.name === 'All' + ? style.getPropertyValue('--white-1') + : colors[index % colors.length]; return { type: 'line', label: item.name, diff --git a/pontoon/insights/tests/test_views.py b/pontoon/insights/tests/test_views.py index 687cc16c5b..98d6ba3848 100644 --- a/pontoon/insights/tests/test_views.py +++ b/pontoon/insights/tests/test_views.py @@ -145,7 +145,7 @@ def test_default_with_data( team_pretranslation_quality = response_context["team_pretranslation_quality"] assert team_pretranslation_quality["dataset"] == [ { - "name": "All", + "name": f"{locale_a.name} · {locale_a.code}", "approval_rate": [ None, None, @@ -162,7 +162,7 @@ def test_default_with_data( ], }, { - "name": f"{locale_a.name} · {locale_a.code}", + "name": "All", "approval_rate": [ None, None, @@ -182,7 +182,7 @@ def test_default_with_data( project_pretranslation_quality = response_context["project_pretranslation_quality"] assert project_pretranslation_quality["dataset"] == [ { - "name": "All", + "name": project_a.name, "approval_rate": [ None, None, @@ -199,7 +199,7 @@ def test_default_with_data( ], }, { - "name": project_a.name, + "name": "All", "approval_rate": [ None, None, diff --git a/pontoon/insights/utils.py b/pontoon/insights/utils.py index 9306b03cb6..7319221a10 100644 --- a/pontoon/insights/utils.py +++ b/pontoon/insights/utils.py @@ -469,12 +469,7 @@ def get_global_pretranslation_quality(category, id): .order_by("month") ) - data = { - "all": { - "name": "All", - "approval_rate": [None] * 12, - } - } + data = {} approved = "pretranslations_approved_sum" rejected = "pretranslations_rejected_sum" @@ -506,6 +501,14 @@ def get_global_pretranslation_quality(category, id): totals[month_index][approved] += action[approved] totals[month_index][rejected] += action[rejected] + data.update( + { + "all": { + "name": "All", + "approval_rate": [None] * 12, + } + } + ) # Monthly totals across the entire category total_approval_rates = data["all"]["approval_rate"] for idx, _ in enumerate(total_approval_rates): @@ -516,7 +519,7 @@ def get_global_pretranslation_quality(category, id): return { "dates": sorted(list({convert_to_unix_time(x["month"]) for x in actions})), - "dataset": [v for _, v in data.items()], + "dataset": list(data.values()), } From 4f4e716844c5fd053a163d04c519df6e0d823474 Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:37:50 -0400 Subject: [PATCH 33/34] Update collect_chs_snapshots.py --- pontoon/insights/management/commands/collect_chs_snapshots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pontoon/insights/management/commands/collect_chs_snapshots.py b/pontoon/insights/management/commands/collect_chs_snapshots.py index ad6df9b316..e5843d21f9 100644 --- a/pontoon/insights/management/commands/collect_chs_snapshots.py +++ b/pontoon/insights/management/commands/collect_chs_snapshots.py @@ -12,7 +12,7 @@ def add_arguments(self, parser): parser.add_argument( "--force", action="store_true", - help="Force sending regardless of the current date.", + help="Force collection regardless of the current date.", ) def handle(self, *args, **options): From c982187bd962f59c984c7db3dccf0d01e4758ede Mon Sep 17 00:00:00 2001 From: functionzz <164675620+functionzz@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:53:38 -0400 Subject: [PATCH 34/34] remove repetitive code --- pontoon/insights/chs.py | 44 ++++++++++++++++------------------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py index f1ccf9fc36..acaae1a050 100644 --- a/pontoon/insights/chs.py +++ b/pontoon/insights/chs.py @@ -190,6 +190,15 @@ def get_contributor_metrics_by_locale(locales, end_date: datetime) -> dict[int, return locale_contributors +def scaled_points(count, points) -> float: + """Award full points for 2+ people, half for exactly 1, none otherwise.""" + if count >= 2: + return points + if count >= 1: + return points / 2 + return 0 + + def compute_chs(args: dict) -> float: active_managers = args.get("active_managers", 0) active_translators = args.get("active_translators", 0) @@ -201,33 +210,14 @@ def compute_chs(args: dict) -> float: total_manager_points = MANAGER_POINTS if active_managers >= 1 else 0 - if active_translators >= 2: - total_translator_points = TRANSLATOR_POINTS - elif active_translators >= 1: - total_translator_points = TRANSLATOR_POINTS / 2 - else: - total_translator_points = 0 - - if active_contributors >= 2: - total_active_contributor_points = ACTIVE_CONTRIBUTOR_POINTS - elif active_contributors >= 1: - total_active_contributor_points = ACTIVE_CONTRIBUTOR_POINTS / 2 - else: - total_active_contributor_points = 0 - - if all_contributors >= 2: - total_all_contributor_points = ALL_CONTRIBUTOR_POINTS - elif all_contributors >= 1: - total_all_contributor_points = ALL_CONTRIBUTOR_POINTS / 2 - else: - total_all_contributor_points = 0 - - if new_signups >= 2: - total_new_signup_points = NEW_SIGNUP_POINTS - elif new_signups >= 1: - total_new_signup_points = NEW_SIGNUP_POINTS / 2 - else: - total_new_signup_points = 0 + total_translator_points = scaled_points(active_translators, TRANSLATOR_POINTS) + total_active_contributor_points = scaled_points( + active_contributors, ACTIVE_CONTRIBUTOR_POINTS + ) + total_all_contributor_points = scaled_points( + all_contributors, ALL_CONTRIBUTOR_POINTS + ) + total_new_signup_points = scaled_points(new_signups, NEW_SIGNUP_POINTS) total_enabled_project_points = round( (key_projects_enabled / len(KEY_PROJECT_SLUGS)) * ENABLED_PROJECT_POINTS, 2
Locale Managers TranslatorsContr. 1Contr. 2 + Contr. 1 + + Contr. 2 + NewProjects + Projects + Completion Score