diff --git a/documentation/docs/dev/deployment.md b/documentation/docs/dev/deployment.md index 3a65cea071..dd1199cc91 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 +`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 +``` + ### Warm up cache We cache data for some of the views (e.g. Contributors) for a day. Some diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index 2841893bee..1bdc84a4ad 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -333,6 +333,16 @@ class Meta: fields = ("locales_order",) +class UserInsightsDashboardConfigForm(forms.ModelForm): + """ + Form is responsible for saving custom configurations of the Insights 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/migrations/0118_merge_20260624_0350.py b/pontoon/base/migrations/0118_merge_20260624_0350.py new file mode 100644 index 0000000000..3e7bb6c7bb --- /dev/null +++ b/pontoon/base/migrations/0118_merge_20260624_0350.py @@ -0,0 +1,12 @@ +# 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 = [] 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..10c4d8a7e3 --- /dev/null +++ b/pontoon/base/migrations/0119_merge_20260624_1835.py @@ -0,0 +1,12 @@ +# 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 = [] 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/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/insights/admin.py b/pontoon/insights/admin.py index fce81ecfb0..25f65c8dfd 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 ( + LocaleHealthSnapshot, LocaleInsightsSnapshot, ProjectLocaleInsightsSnapshot, ) @@ -39,5 +40,35 @@ class ProjectLocaleInsightsSnapshotAdmin(admin.ModelAdmin): readonly_fields = ("project_locale",) +class LocaleHealthSnapshotAdmin(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", + "all_contributors", + "new_signups", + "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(LocaleHealthSnapshot, LocaleHealthSnapshotAdmin) diff --git a/pontoon/insights/chs.py b/pontoon/insights/chs.py new file mode 100644 index 0000000000..f1ccf9fc36 --- /dev/null +++ b/pontoon/insights/chs.py @@ -0,0 +1,291 @@ +from collections import defaultdict +from datetime import datetime + +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 +from pontoon.base.models.project import Project +from pontoon.base.models.project_locale import ProjectLocale +from pontoon.insights.models import LocaleHealthSnapshot +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 = [ + "firefox-for-android", + "firefox-for-ios", + "mozilla-monitor-website", + "firefox-relay-website", + "firefox", + "mozilla-accounts", + "mozilla-vpn-client", +] + + +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", + ) + .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_date. + """ + start_date = end_date - relativedelta(months=13) + + 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__is_active=True, + 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, + "all_contributors": 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 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 approved > ACTIVE_CONTRIBUTOR_STRING_THRESHOLD: + 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 + + 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) + 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.00) + + 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_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 + + total_translator_points + + total_active_contributor_points + + total_all_contributor_points + + total_new_signup_points + + total_enabled_project_points + + total_completion_points, + 2, + ) + + 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(locales=None) -> list[LocaleHealthSnapshot]: + """Assemble one LocaleHealthSnapshot per visible locale for today.""" + + now = timezone.now() + 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) + contributors = get_contributor_metrics_by_locale(locales, now) + + 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), + "all_contributors": c.get("all_contributors", 0), + "new_signups": c.get("new_signups", 0), + } + chs_fields = compute_chs(args) + + snapshots.append( + LocaleHealthSnapshot(locale=locale, created_at=now, **args, **chs_fields) + ) + + return snapshots diff --git a/pontoon/insights/management/commands/collect_chs_snapshots.py b/pontoon/insights/management/commands/collect_chs_snapshots.py new file mode 100644 index 0000000000..ad6df9b316 --- /dev/null +++ b/pontoon/insights/management/commands/collect_chs_snapshots.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils.timezone import now + +from pontoon.insights.tasks import collect_chs_snapshots + + +class Command(BaseCommand): + 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): + """ + Collects per-locale Community Health Score (CHS) metrics, saved as + LocaleHealthSnapshots. + + 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. + """ + 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/migrations/0021_localehealthsnapshot.py b/pontoon/insights/migrations/0021_localehealthsnapshot.py new file mode 100644 index 0000000000..30e80141d2 --- /dev/null +++ b/pontoon/insights/migrations/0021_localehealthsnapshot.py @@ -0,0 +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"), + ] + + 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/models.py b/pontoon/insights/models.py index e788c9e956..c9cbc68f7b 100644 --- a/pontoon/insights/models.py +++ b/pontoon/insights/models.py @@ -83,3 +83,37 @@ class LocaleInsightsSnapshot(InsightsSnapshot): class ProjectLocaleInsightsSnapshot(InsightsSnapshot): project_locale = models.ForeignKey("base.ProjectLocale", models.CASCADE) + + +class LocaleHealthSnapshot(models.Model): + locale = models.ForeignKey("base.Locale", on_delete=models.CASCADE) + created_at = models.DateField() + 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.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")] + indexes = [models.Index(fields=["created_at", "locale"])] diff --git a/pontoon/insights/static/css/config.css b/pontoon/insights/static/css/config.css new file mode 100644 index 0000000000..317d1a5903 --- /dev/null +++ b/pontoon/insights/static/css/config.css @@ -0,0 +1,62 @@ +.admin-project { + overflow: auto; + position: relative; + + .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/insights/static/css/insights.css b/pontoon/insights/static/css/insights.css index e1c4127c49..4d7cf18e47 100644 --- a/pontoon/insights/static/css/insights.css +++ b/pontoon/insights/static/css/insights.css @@ -1,28 +1,142 @@ -#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%; + + th:first-child { + text-align: center; + } + + &.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/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..b7dfeaa708 100644 --- a/pontoon/insights/static/js/insights.js +++ b/pontoon/insights/static/js/insights.js @@ -9,19 +9,47 @@ 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, { 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 +75,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 +88,7 @@ var Pontoon = (function (my) { fill: true, tension: 0.4, order: color.length - index, + hidden: item.name === 'All' ? false : true, }; }); diff --git a/pontoon/insights/static/js/insights_tab.js b/pontoon/insights/static/js/insights_tab.js index 1df745f6e6..a7431f1868 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,92 @@ 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: { + 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/tasks.py b/pontoon/insights/tasks.py index dc52900b40..63dcc0f699 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 ( + LocaleHealthSnapshot, LocaleInsightsSnapshot, ProjectLocaleInsightsSnapshot, ) @@ -615,3 +617,15 @@ def get_active_users( "reviewers": len(active_reviewers & locale_reviewers), "contributors": len(active_contributors & locale_contributors), } + + +@shared_task +def collect_chs_snapshots(): + """Collect monthly LocaleHealthSnapshots (CHS), one per available locale.""" + + log.info("Start collecting CHS snapshots...") + + now = timezone.now() + snapshots = build_chs_snapshots() + LocaleHealthSnapshot.objects.bulk_create(snapshots, ignore_conflicts=True) + log.info(f"Collected CHS snapshots for {now}: {len(snapshots)} snapshots created.") diff --git a/pontoon/insights/templates/insights/config.html b/pontoon/insights/templates/insights/config.html new file mode 100644 index 0000000000..1bf9e54c58 --- /dev/null +++ b/pontoon/insights/templates/insights/config.html @@ -0,0 +1,47 @@ +{% 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 %} +
+

Insights Dashboard Configuration

+ + {% csrf_token %} + +
+

+ Target Locales +

+
+ {{ + multiple_item_selector.render( + available_locales, + selected_locales, + form_field='dashboard_locales', + ) + }} +
+
+ +
+ + Cancel +
+
+{% endblock %} + +{% block extend_css %} + {% stylesheet 'insights' %} +{% endblock %} + +{% block extend_js %} + {% javascript 'insights' %} +{% endblock %} diff --git a/pontoon/insights/templates/insights/insights.html b/pontoon/insights/templates/insights/insights.html index ce1ab6b79b..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,70 @@
+ +
+ + +
+
+ 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 %} +
+
+

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

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

@@ -23,8 +88,8 @@

@@ -44,8 +109,8 @@

diff --git a/pontoon/insights/templates/insights/widgets/insights.html b/pontoon/insights/templates/insights/widgets/insights.html index 001724a2b9..dcaae1010b 100644 --- a/pontoon/insights/templates/insights/widgets/insights.html +++ b/pontoon/insights/templates/insights/widgets/insights.html @@ -3,7 +3,7 @@ {# Widget to display insights. #} {% macro display() %} -
+
{% if total_users %}

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

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

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

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

+ Community health score + {{ + Tooltip.display( + intro='Community health score at a particular point in time.', + items=[{ + 'class': 'current-month', + 'name': 'Current month', + 'definition': 'Community health score calculated for a specific month.', + }] + ) + }} +

+ + +
+
+ {% endif %} +

@@ -178,11 +206,11 @@

@@ -220,10 +248,10 @@

@@ -265,11 +293,11 @@

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