From 33c5671c531233d665dfd5f5b4f978e844947a2a Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 15 Jun 2026 09:07:07 -0700 Subject: [PATCH] improve aggregated metrics view --- kitsune/dashboards/api.py | 49 ------ .../jinja2/dashboards/aggregated_metrics.html | 12 +- .../jinja2/dashboards/locale_metrics.html | 10 +- kitsune/dashboards/metrics.py | 93 +++++++++++ kitsune/dashboards/tasks.py | 7 + kitsune/dashboards/tests/test_api.py | 132 --------------- kitsune/dashboards/tests/test_metrics.py | 158 ++++++++++++++++++ kitsune/dashboards/urls.py | 7 + kitsune/dashboards/views.py | 19 ++- .../sumo/static/sumo/js/charts/wikiMetrics.js | 125 ++++++-------- kitsune/urls.py | 3 - 11 files changed, 339 insertions(+), 276 deletions(-) delete mode 100644 kitsune/dashboards/api.py create mode 100644 kitsune/dashboards/metrics.py delete mode 100644 kitsune/dashboards/tests/test_api.py create mode 100644 kitsune/dashboards/tests/test_metrics.py diff --git a/kitsune/dashboards/api.py b/kitsune/dashboards/api.py deleted file mode 100644 index 02dc09966c8..00000000000 --- a/kitsune/dashboards/api.py +++ /dev/null @@ -1,49 +0,0 @@ -import django_filters -from rest_framework import generics -from rest_framework.relations import SlugRelatedField -from rest_framework.serializers import ModelSerializer - -from kitsune.dashboards.models import WikiMetric -from kitsune.products.models import Product - - -class WikiMetricSerializer(ModelSerializer): - product = SlugRelatedField(slug_field="slug", queryset=Product.objects.all()) - - class Meta: - model = WikiMetric - fields = ("code", "locale", "product", "date", "value") - - -# Note: I hate that I had to create this class just to make -# product= act like product__slug= -# Is there a better way? -class ProductFilter(django_filters.Filter): - """A custom filter to map 'product' to 'product__slug'.""" - - def filter(self, qs, value): - if value is None: - return qs - - if value == "" or value == "null": - return qs.filter(product=None) - - return qs.filter(product__slug=value) - - -class WikiMetricFilterSet(django_filters.FilterSet): - """A custom filter set for WikiMetrics for use by the API.""" - - product = ProductFilter() - - class Meta: - model = WikiMetric - fields = ["code", "locale", "product"] - - -class WikiMetricList(generics.ListAPIView): - """The API list view for WikiMetrics.""" - - queryset = WikiMetric.objects.all() - serializer_class = WikiMetricSerializer - filterset_class = WikiMetricFilterSet diff --git a/kitsune/dashboards/jinja2/dashboards/aggregated_metrics.html b/kitsune/dashboards/jinja2/dashboards/aggregated_metrics.html index 60ea8de4b5c..91e4daff0f5 100644 --- a/kitsune/dashboards/jinja2/dashboards/aggregated_metrics.html +++ b/kitsune/dashboards/jinja2/dashboards/aggregated_metrics.html @@ -6,8 +6,6 @@ {% set crumbs = [(None, title)] %} {% set classes = 'aggregated-metrics' %} -{% set product_slug = product.slug if product else 'null' %} - {% block content %}
@@ -19,23 +17,23 @@

{{ title }}

{{ _('The dashboard data is being loaded. This may take some time.') }} -
-
+
+
{# Double % required below to escape % for gettext #}

{{ _('Top 100 Articles: %% Localized') }}

-
+
{# Double % required below to escape % for gettext #}

{{ _('Top 20 Articles: %% Localized') }}

-
+
{# Double % required below to escape % for gettext #}

{{ _('All Articles: %% Localized') }}

-
+

{{ _('Active Contributors') }}

diff --git a/kitsune/dashboards/jinja2/dashboards/locale_metrics.html b/kitsune/dashboards/jinja2/dashboards/locale_metrics.html index 82b7d61343c..15eee103eea 100644 --- a/kitsune/dashboards/jinja2/dashboards/locale_metrics.html +++ b/kitsune/dashboards/jinja2/dashboards/locale_metrics.html @@ -6,9 +6,6 @@ {% set crumbs = [(None, title)] %} {% set classes = 'locale-metrics' %} -{% set product_slug = product.slug if product else 'null' %} - - {% block content %}

{{ title }}

@@ -16,16 +13,17 @@

{{ title }}

{{ product_choice_list(products, product, url('dashboards.locale_metrics', locale_code=current_locale)) }} {% if current_locale != settings.WIKI_DEFAULT_LANGUAGE %} -
+

{{ _('Localization Percentage') }}

{% endif %} -
+

{{ _('Active Contributors') }}

-
+ {# The kb-votes KPI endpoint uses the literal string "null" as its "all products" sentinel. #} +

{{ _('Helpful Votes') }}

diff --git a/kitsune/dashboards/metrics.py b/kitsune/dashboards/metrics.py new file mode 100644 index 00000000000..bc3fb5489b3 --- /dev/null +++ b/kitsune/dashboards/metrics.py @@ -0,0 +1,93 @@ +from datetime import date, timedelta + +from django.core.cache import cache +from django.db.models.functions import TruncWeek + +from kitsune.dashboards.models import WikiMetric +from kitsune.products.models import Product + +# The metrics dashboards always show a fixed one-year window. +WINDOW_DAYS = 365 + +# The coverage cron re-warms these payloads once a day. Keep the TTL comfortably +# longer than that 24-hour interval so a successful warm always overwrites a +# still-live entry; if the TTL matched the interval, expiry would race the next +# warm and leave a cold gap (forcing an on-request rebuild) on roughly half the +# days. The extra two hours absorb cron jitter and Celery queue latency. The +# cache only goes cold when a warm is actually missed. +WIKI_METRICS_CACHE_TIMEOUT = 26 * 60 * 60 # 26 hours + + +def _build_payload(product, locale): + """Build the shaped metrics payload from the database. + + The daily coverage codes are downsampled to one point per ISO week (the + most recent row in each week); the monthly active-contributors code already + has at most one row per month, so it passes through unchanged. + + Returns {code: {locale: [{"date": iso, "value": float}, ...ascending]}}. + """ + cutoff = date.today() - timedelta(days=WINDOW_DAYS) + + queryset = WikiMetric.objects.filter(date__gte=cutoff, product=product) + if locale: + queryset = queryset.filter(locale=locale) + + # We keep one row per (code, locale, week). Weeks are returned + # ascending, so each series is already in ascending-date order. + rows = ( + queryset.annotate(week=TruncWeek("date")) + .order_by("code", "locale", "week", "-date", "-id") + .distinct("code", "locale", "week") + .values("code", "locale", "date", "value", "week") + ) + + payload = {} + for row in rows: + by_locale = payload.setdefault(row["code"], {}) + by_locale.setdefault(row["locale"] or "", []).append( + {"date": row["date"].isoformat(), "value": row["value"]} + ) + + return payload + + +def _cache_key(product, locale): + product_key = product.slug if product else "all" + locale_key = locale or "all" + return f"wikimetrics:agg:{product_key}:{locale_key}" + + +def get_wiki_metrics_data(product, locale=None): + """Return the cached metrics payload for the given product/locale. + + `product` is a Product instance or None (the "All products" bucket). + `locale` limits the payload to a single locale (used by the per-locale + dashboard). The payload always covers a fixed one-year window. + + Cached for WIKI_METRICS_CACHE_TIMEOUT. The data-refresh cron tasks call + warm_wiki_metrics_cache() to recompute and reset this entry, so in normal + operation the TTL is just a backstop for a missed cron run. + """ + return cache.get_or_set( + _cache_key(product, locale), + lambda: _build_payload(product, locale), + WIKI_METRICS_CACHE_TIMEOUT, + ) + + +def warm_wiki_metrics_cache(): + """Recompute and cache the aggregated (all-locales) payloads. + + Called by the cron tasks after the WikiMetric rows are refreshed. Using + cache.set (not get_or_set) overwrites the existing entries and resets their + TTL, so the expensive all-locales query is never run on a user request and + there's no cold-cache stampede. The per-locale payloads are cheap and left + to populate on demand. + """ + for product in [None, *Product.objects.filter(visible=True)]: + cache.set( + _cache_key(product, None), + _build_payload(product, None), + WIKI_METRICS_CACHE_TIMEOUT, + ) diff --git a/kitsune/dashboards/tasks.py b/kitsune/dashboards/tasks.py index 1729da40fc5..bf162ff5b53 100644 --- a/kitsune/dashboards/tasks.py +++ b/kitsune/dashboards/tasks.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db import connection +from kitsune.dashboards.metrics import warm_wiki_metrics_cache from kitsune.dashboards.models import ( L10N_ACTIVE_CONTRIBUTORS_CODE, L10N_ALL_CODE, @@ -104,6 +105,9 @@ def update_l10n_coverage_metrics() -> None: code=L10N_ALL_CODE, locale=locale, product=product, date=today, value=percent ) + # Warm the cached dashboard payloads with the freshly-computed data. + warm_wiki_metrics_cache() + @shared_task @skip_if_read_only_mode @@ -260,3 +264,6 @@ def update_l10n_contributor_metrics(day_isoformat: str | None = None) -> None: date=previous_first_of_month, value=num, ) + + # Warm the cached dashboard payloads with the freshly-computed data. + warm_wiki_metrics_cache() diff --git a/kitsune/dashboards/tests/test_api.py b/kitsune/dashboards/tests/test_api.py deleted file mode 100644 index d6032292cf0..00000000000 --- a/kitsune/dashboards/tests/test_api.py +++ /dev/null @@ -1,132 +0,0 @@ -import json -from datetime import date, timedelta - -from kitsune.dashboards.models import METRIC_CODE_CHOICES -from kitsune.dashboards.tests import WikiMetricFactory -from kitsune.products.tests import ProductFactory -from kitsune.sumo.templatetags.jinja_helpers import urlparams -from kitsune.sumo.tests import TestCase -from kitsune.sumo.urlresolvers import reverse - - -class WikiMetricAPITests(TestCase): - def test_default(self): - """Test the default API call (no filtering).""" - today = date.today() - - # Create 10 wikimetrics. - for i in range(10): - WikiMetricFactory( - code=METRIC_CODE_CHOICES[i % len(METRIC_CODE_CHOICES)][0], - date=today - timedelta(days=i), - value=i, - ) - - # Call the API. - response = self.client.get(urlparams(reverse("api.wikimetric_list"), format="json")) - self.assertEqual(200, response.status_code) - - results = json.loads(response.content)["results"] - - # Verify the results are what we created. - self.assertEqual(10, len(results)) - for i in range(10): - result = results[i] - self.assertEqual(i, result["value"]) - self.assertEqual(METRIC_CODE_CHOICES[i % len(METRIC_CODE_CHOICES)][0], result["code"]) - self.assertEqual(str(today - timedelta(days=i)), result["date"]) - - def test_product_filter(self): - """Test filtering results by product.""" - today = date.today() - - # Create products and associated wiki metrics. - p1 = ProductFactory() - p2 = ProductFactory() - - # Create 3 for each product: - for i in range(3): - for p in [p1, p2]: - WikiMetricFactory(date=today - timedelta(days=i), product=p) - # Create one more for p2. - WikiMetricFactory(date=today - timedelta(days=4), product=p2) - - # Call and verify the API for product=p1. - response = self.client.get( - urlparams(reverse("api.wikimetric_list"), format="json", product=p1.slug) - ) - self.assertEqual(200, response.status_code) - - results = json.loads(response.content)["results"] - self.assertEqual(3, len(results)) - - # Call and verify the API for product=p1. - response = self.client.get( - urlparams(reverse("api.wikimetric_list"), format="json", product=p2.slug) - ) - self.assertEqual(200, response.status_code) - - results = json.loads(response.content)["results"] - self.assertEqual(4, len(results)) - - def test_locale_filter(self): - """Test filtering results by locale.""" - today = date.today() - - # Create 3 wikimetrics for es: - for i in range(3): - WikiMetricFactory(locale="es", date=today - timedelta(days=i)) - - # Create 1 for fr: - WikiMetricFactory(locale="fr") - - # Call and verify the API for locale=es. - response = self.client.get( - urlparams(reverse("api.wikimetric_list"), format="json", locale="es") - ) - self.assertEqual(200, response.status_code) - - results = json.loads(response.content)["results"] - self.assertEqual(3, len(results)) - - # Call and verify the API for locale=fr. - response = self.client.get( - urlparams(reverse("api.wikimetric_list"), format="json", locale="fr") - ) - self.assertEqual(200, response.status_code) - - results = json.loads(response.content)["results"] - self.assertEqual(1, len(results)) - - def test_code_filter(self): - """Test filtering results by code.""" - today = date.today() - - # Create 3 wikimetrics for active_contributors: - for i in range(3): - WikiMetricFactory(code=METRIC_CODE_CHOICES[0][0], date=today - timedelta(days=i)) - - # Create 1 for percent_localized_all: - WikiMetricFactory(code=METRIC_CODE_CHOICES[1][0]) - - # Call and verify the API for code=METRIC_CODE_CHOICES[0]. - response = self.client.get( - urlparams( - reverse("api.wikimetric_list"), format="json", code=METRIC_CODE_CHOICES[0][0] - ) - ) - self.assertEqual(200, response.status_code) - - results = json.loads(response.content)["results"] - self.assertEqual(3, len(results)) - - # Call and verify the API for code=METRIC_CODE_CHOICES[1]. - response = self.client.get( - urlparams( - reverse("api.wikimetric_list"), format="json", code=METRIC_CODE_CHOICES[1][0] - ) - ) - self.assertEqual(200, response.status_code) - - results = json.loads(response.content)["results"] - self.assertEqual(1, len(results)) diff --git a/kitsune/dashboards/tests/test_metrics.py b/kitsune/dashboards/tests/test_metrics.py new file mode 100644 index 00000000000..ba97a1074c5 --- /dev/null +++ b/kitsune/dashboards/tests/test_metrics.py @@ -0,0 +1,158 @@ +from datetime import date, timedelta +from unittest import mock + +from django.core.cache import cache + +from kitsune.dashboards.metrics import warm_wiki_metrics_cache +from kitsune.dashboards.models import ( + L10N_ACTIVE_CONTRIBUTORS_CODE, + L10N_TOP20_CODE, +) +from kitsune.dashboards.tests import WikiMetricFactory +from kitsune.products.tests import ProductFactory +from kitsune.sumo.tests import TestCase +from kitsune.sumo.urlresolvers import reverse + + +class MetricsPageTests(TestCase): + """The dashboard pages render for anonymous users (they are public).""" + + @mock.patch( + "kitsune.dashboards.views.get_locales_by_visit", return_value=[("es", 1), ("fr", 2)] + ) + def test_aggregated_page_renders(self, mocked_locales): + response = self.client.get(reverse("dashboards.aggregated_metrics")) + self.assertEqual(response.status_code, 200) + + def test_locale_page_renders(self): + response = self.client.get(reverse("dashboards.locale_metrics", args=["es"])) + self.assertEqual(response.status_code, 200) + + +class MetricsDataTests(TestCase): + """Shape, one-year window, downsampling, and caching of the data endpoint.""" + + def setUp(self): + super().setUp() + self.url = reverse("dashboards.wiki_metrics_data") + + def _payload(self, **params): + response = self.client.get(self.url, params) + self.assertEqual(response.status_code, 200) + return response.json() + + def test_grouped_by_code_then_locale(self): + today = date.today() + WikiMetricFactory(code=L10N_TOP20_CODE, locale="es", date=today, value=50.0) + WikiMetricFactory(code=L10N_TOP20_CODE, locale="fr", date=today, value=60.0) + + data = self._payload() + + self.assertEqual(data[L10N_TOP20_CODE]["es"], [{"date": today.isoformat(), "value": 50.0}]) + self.assertEqual(data[L10N_TOP20_CODE]["fr"], [{"date": today.isoformat(), "value": 60.0}]) + + def test_weekly_downsample_keeps_latest_in_week(self): + # Two daily rows in the same ISO week collapse to the most recent. + base = date.today() - timedelta(days=14) + monday = base - timedelta(days=base.weekday()) + WikiMetricFactory(code=L10N_TOP20_CODE, locale="es", date=monday, value=10.0) + WikiMetricFactory( + code=L10N_TOP20_CODE, locale="es", date=monday + timedelta(days=3), value=20.0 + ) + + series = self._payload()[L10N_TOP20_CODE]["es"] + + self.assertEqual( + series, [{"date": (monday + timedelta(days=3)).isoformat(), "value": 20.0}] + ) + + def test_duplicate_same_date_keeps_latest_row(self): + # Two rows sharing a (code, locale, date) — e.g. the daily cron running + # twice — collapse to the most recently inserted (highest id) one. + today = date.today() + WikiMetricFactory(code=L10N_TOP20_CODE, locale="es", date=today, value=10.0) + WikiMetricFactory(code=L10N_TOP20_CODE, locale="es", date=today, value=20.0) + + series = self._payload()[L10N_TOP20_CODE]["es"] + + self.assertEqual(series, [{"date": today.isoformat(), "value": 20.0}]) + + def test_excludes_rows_older_than_a_year(self): + WikiMetricFactory( + code=L10N_TOP20_CODE, locale="es", date=date.today() - timedelta(days=30), value=1.0 + ) + WikiMetricFactory( + code=L10N_TOP20_CODE, locale="es", date=date.today() - timedelta(days=400), value=2.0 + ) + + # Only the row within the fixed one-year window is returned. + self.assertEqual([p["value"] for p in self._payload()[L10N_TOP20_CODE]["es"]], [1.0]) + + def test_locale_filter_limits_payload(self): + today = date.today() + WikiMetricFactory(code=L10N_TOP20_CODE, locale="es", date=today, value=1.0) + WikiMetricFactory(code=L10N_TOP20_CODE, locale="fr", date=today, value=2.0) + + data = self._payload(locale="es") + + self.assertEqual(list(data[L10N_TOP20_CODE].keys()), ["es"]) + + def test_unknown_locale_404(self): + response = self.client.get(self.url, {"locale": "zz"}) + self.assertEqual(response.status_code, 404) + + def test_product_bucket_isolation(self): + today = date.today() + product = ProductFactory() + WikiMetricFactory(code=L10N_TOP20_CODE, locale="es", date=today, value=1.0, product=None) + WikiMetricFactory( + code=L10N_TOP20_CODE, locale="es", date=today, value=2.0, product=product + ) + + # No product param => the "All products" (null) bucket only. + self.assertEqual([p["value"] for p in self._payload()[L10N_TOP20_CODE]["es"]], [1.0]) + # A product slug => that product's bucket only. + self.assertEqual( + [p["value"] for p in self._payload(product=product.slug)[L10N_TOP20_CODE]["es"]], + [2.0], + ) + + def test_monthly_contributors_pass_through(self): + first_of_month = date.today().replace(day=1) + WikiMetricFactory( + code=L10N_ACTIVE_CONTRIBUTORS_CODE, locale="es", date=first_of_month, value=7.0 + ) + + data = self._payload() + + self.assertEqual( + data[L10N_ACTIVE_CONTRIBUTORS_CODE]["es"], + [{"date": first_of_month.isoformat(), "value": 7.0}], + ) + + def test_response_is_cached_until_expiry(self): + WikiMetricFactory(code=L10N_TOP20_CODE, locale="es", date=date.today(), value=1.0) + self.assertIn("es", self._payload()[L10N_TOP20_CODE]) + + # A new row is not reflected while the cached payload is still valid. + WikiMetricFactory(code=L10N_TOP20_CODE, locale="fr", date=date.today(), value=2.0) + self.assertNotIn("fr", self._payload()[L10N_TOP20_CODE]) + + # Simulate the payload's TTL expiring by dropping its cache entry (the + # default request maps to the all-products/all-locales key); the next + # request then rebuilds it from the current data. + cache.delete("wikimetrics:agg:all:all") + self.assertIn("fr", self._payload()[L10N_TOP20_CODE]) + + def test_warm_cache_refreshes_aggregated_payload(self): + WikiMetricFactory(code=L10N_TOP20_CODE, locale="es", date=date.today(), value=1.0) + self.assertIn("es", self._payload()[L10N_TOP20_CODE]) + + # A new row isn't visible while the cached payload is still valid... + WikiMetricFactory(code=L10N_TOP20_CODE, locale="fr", date=date.today(), value=2.0) + self.assertNotIn("fr", self._payload()[L10N_TOP20_CODE]) + + # ...but warming (what the cron tasks do after refreshing the data) + # overwrites the cached all-locales payload in place, no eviction needed. + warm_wiki_metrics_cache() + self.assertIn("fr", self._payload()[L10N_TOP20_CODE]) diff --git a/kitsune/dashboards/urls.py b/kitsune/dashboards/urls.py index 40b2c035628..2a38152bcc7 100644 --- a/kitsune/dashboards/urls.py +++ b/kitsune/dashboards/urls.py @@ -33,6 +33,13 @@ views.aggregated_metrics, name="dashboards.aggregated_metrics", ), + # JSON data feeding the kb metrics dashboards (must precede the per-locale + # pattern below, which would otherwise match "metrics/data"). + re_path( + r"^kb/dashboard/metrics/data$", + views.wiki_metrics_data, + name="dashboards.wiki_metrics_data", + ), # The per-locale kb metrics dashboard. re_path( r"^kb/dashboard/metrics/(?P[^/]+)$", diff --git a/kitsune/dashboards/views.py b/kitsune/dashboards/views.py index 99336a6dc5f..15486228e03 100644 --- a/kitsune/dashboards/views.py +++ b/kitsune/dashboards/views.py @@ -3,12 +3,13 @@ from datetime import date, timedelta from django.conf import settings -from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, render from django.utils.translation import gettext as _ from django.views.decorators.http import require_GET from kitsune.dashboards import PERIODS +from kitsune.dashboards.metrics import get_wiki_metrics_data from kitsune.dashboards.readouts import ( CONTRIBUTOR_READOUTS, L10N_READOUTS, @@ -246,6 +247,22 @@ def aggregated_metrics(request): ) +@require_GET +def wiki_metrics_data(request): + """Return shaped WikiMetric data (a fixed one-year window) for the dashboards. + + Serves both the aggregated dashboard (all locales) and the per-locale + dashboard (when a `locale` query parameter is given), in a single response. + """ + product = _get_product(request) + + locale = request.GET.get("locale") or None + if locale and locale not in settings.SUMO_LANGUAGES: + raise Http404 + + return JsonResponse(get_wiki_metrics_data(product, locale)) + + def _get_product(request): product_slug = request.GET.get("product") if product_slug: diff --git a/kitsune/sumo/static/sumo/js/charts/wikiMetrics.js b/kitsune/sumo/static/sumo/js/charts/wikiMetrics.js index 12bbcde57e7..488fb1358a7 100644 --- a/kitsune/sumo/static/sumo/js/charts/wikiMetrics.js +++ b/kitsune/sumo/static/sumo/js/charts/wikiMetrics.js @@ -18,24 +18,34 @@ document.addEventListener("DOMContentLoaded", () => { if (document.body.classList.contains("aggregated-metrics")) { initAggregatedMetrics(); } - // Hook for iteration 5 — locale-metrics page reuses this module if (document.body.classList.contains("locale-metrics")) { initLocaleMetrics(); } }); async function initAggregatedMetrics() { - const firstSection = document.getElementById("percent-localized-top100"); - if (!firstSection) return; - const url = firstSection.dataset.url; + const container = document.getElementById("dashboard-readouts"); + const url = container?.dataset.url; if (!url) return; - const allResults = await fetchAllPages(url, 60); + let payload; + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + payload = await response.json(); + } catch { + const loading = document.querySelector(".loading-data"); + if (loading) loading.textContent = gettext("Error loading dashboard data"); + return; + } + document.querySelector(".loading-data")?.remove(); - document.getElementById("dashboard-readouts").style.display = ""; + container.style.display = ""; - const grouped = groupResultsByCode(allResults); - const allLocales = [...new Set(allResults.map((r) => r.locale))].sort(); + // Every locale present across any code. + const allLocales = [ + ...new Set(Object.values(payload).flatMap((byLocale) => Object.keys(byLocale))), + ].sort(); const picker = document.getElementById("locale-picker"); const sidebarLocales = picker?.dataset.locales @@ -56,8 +66,8 @@ async function initAggregatedMetrics() { for (const cfg of CHART_CONFIGS) { const section = document.getElementById(cfg.id); if (!section) continue; - const dataForCode = grouped.get(cfg.code) || new Map(); - const chart = renderMultiLocaleChart(section, dataForCode, allLocales, defaultLocales, cfg); + const byLocale = payload[cfg.code] || {}; + const chart = renderMultiLocaleChart(section, byLocale, allLocales, defaultLocales, cfg); if (chart) charts.push({ chart }); } @@ -76,20 +86,21 @@ async function initLocaleMetrics() { const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); - // Wiki-metric charts — single locale, derive series from `code` + // Wiki-metric charts — single locale, one payload keyed by code. if (wikimetricSection?.dataset.url) { try { - const results = await fetchAllPages(wikimetricSection.dataset.url, 60); - const byCodeAndDate = groupByCodeAndDate(results); + const response = await fetch(wikimetricSection.dataset.url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const byCode = seriesByCode(await response.json()); schedule(() => { const localization = document.getElementById("localization-metrics"); if (localization) { - renderLocaleLocalizationChart(localization, byCodeAndDate); + renderLocaleLocalizationChart(localization, byCode); } const contributors = document.getElementById("active-contributors"); if (contributors) { - renderLocaleContributorsChart(contributors, byCodeAndDate); + renderLocaleContributorsChart(contributors, byCode); } }); } catch { @@ -98,7 +109,7 @@ async function initLocaleMetrics() { } } - // Helpful-votes chart — separate endpoint + // Helpful-votes chart — separate endpoint. if (voteSection?.dataset.url) { try { const response = await fetch(voteSection.dataset.url); @@ -112,12 +123,12 @@ async function initLocaleMetrics() { } } -function groupByCodeAndDate(results) { - // Returns Map> - const out = new Map(); - for (const r of results) { - if (!out.has(r.code)) out.set(r.code, new Map()); - out.get(r.code).set(r.date, r.value); +function seriesByCode(payload) { + // payload is {code: {locale: [{date, value}]}} for a single locale; collapse + // to {code: [{date, value}]} by taking that one locale's series. + const out = {}; + for (const [code, byLocale] of Object.entries(payload)) { + out[code] = Object.values(byLocale)[0] || []; } return out; } @@ -133,7 +144,7 @@ function replaceWithCanvas(section, height = 320) { return canvas; } -function renderLocaleLocalizationChart(section, byCodeAndDate) { +function renderLocaleLocalizationChart(section, byCode) { const canvas = replaceWithCanvas(section); const codes = [ { code: "percent_localized_top100", label: gettext("Top 100 Articles"), color: "#5d84b2" }, @@ -141,26 +152,15 @@ function renderLocaleLocalizationChart(section, byCodeAndDate) { { code: "percent_localized_all", label: gettext("All Articles"), color: "#89a54e" }, ]; - const allDates = new Set(); - for (const { code } of codes) { - const byDate = byCodeAndDate.get(code); - if (byDate) for (const d of byDate.keys()) allDates.add(d); - } - const sortedDates = [...allDates].sort(); - const dateObjs = sortedDates.map(parseISO); - - const datasets = codes.map(({ code, label, color }) => { - const byDate = byCodeAndDate.get(code) || new Map(); - return { - label, - borderColor: color, - backgroundColor: color, - pointRadius: 0, - borderWidth: 1.5, - fill: false, - data: sortedDates.map((d, i) => ({ x: dateObjs[i], y: byDate.has(d) ? byDate.get(d) : null })), - }; - }); + const datasets = codes.map(({ code, label, color }) => ({ + label, + borderColor: color, + backgroundColor: color, + pointRadius: 0, + borderWidth: 1.5, + fill: false, + data: (byCode[code] || []).map((p) => ({ x: parseISO(p.date), y: p.value })), + })); renderLineChart(canvas, lineChartOptions(datasets, { yTitle: gettext("% Localized"), @@ -169,10 +169,8 @@ function renderLocaleLocalizationChart(section, byCodeAndDate) { })); } -function renderLocaleContributorsChart(section, byCodeAndDate) { +function renderLocaleContributorsChart(section, byCode) { const canvas = replaceWithCanvas(section); - const byDate = byCodeAndDate.get("active_contributors") || new Map(); - const sorted = [...byDate.keys()].sort(); const dataset = { label: gettext("Active Contributors"), borderColor: "#5d84b2", @@ -180,7 +178,7 @@ function renderLocaleContributorsChart(section, byCodeAndDate) { pointRadius: 0, borderWidth: 1.5, fill: true, - data: sorted.map(d => ({ x: parseISO(d), y: byDate.get(d) })), + data: (byCode["active_contributors"] || []).map((p) => ({ x: parseISO(p.date), y: p.value })), }; renderLineChart(canvas, lineChartOptions([dataset], { yTitle: gettext("Contributors"), @@ -249,34 +247,7 @@ function lineChartOptions(datasets, { yTitle, max, valueFormatter }) { }; } -async function fetchAllPages(url, maxPages) { - const results = []; - let next = url; - let count = 0; - while (next && count < maxPages) { - const response = await fetch(next); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const data = await response.json(); - results.push(...(data.results || [])); - next = data.next; - count++; - } - return results; -} - -function groupResultsByCode(results) { - // Returns Map>> - const out = new Map(); - for (const r of results) { - if (!out.has(r.code)) out.set(r.code, new Map()); - const byDate = out.get(r.code); - if (!byDate.has(r.date)) byDate.set(r.date, new Map()); - byDate.get(r.date).set(r.locale, r.value); - } - return out; -} - -function renderMultiLocaleChart(section, dataForCode, allLocales, visibleLocales, cfg) { +function renderMultiLocaleChart(section, byLocale, allLocales, visibleLocales, cfg) { const oldRickshaw = section.querySelector(".rickshaw"); if (oldRickshaw) oldRickshaw.remove(); @@ -286,8 +257,6 @@ function renderMultiLocaleChart(section, dataForCode, allLocales, visibleLocales wrap.appendChild(canvas); section.appendChild(wrap); - const dates = Array.from(dataForCode.keys()).sort(); - const dateObjs = dates.map((d) => parseISO(d)); const datasets = allLocales.map((locale, i) => ({ label: locale, borderColor: COLORS[i % COLORS.length], @@ -296,7 +265,7 @@ function renderMultiLocaleChart(section, dataForCode, allLocales, visibleLocales borderWidth: 1.2, fill: false, hidden: !visibleLocales.has(locale), - data: dates.map((d, idx) => ({ x: dateObjs[idx], y: dataForCode.get(d).get(locale) ?? null })), + data: (byLocale[locale] || []).map((p) => ({ x: parseISO(p.date), y: p.value })), })); return renderLineChart(canvas, { diff --git a/kitsune/urls.py b/kitsune/urls.py index 48210df2ef1..1db7354b6c0 100644 --- a/kitsune/urls.py +++ b/kitsune/urls.py @@ -7,7 +7,6 @@ from waffle.views import wafflejs from kitsune.customercare.views import ZendeskWebhookView -from kitsune.dashboards.api import WikiMetricList from kitsune.sumo import views as sumo_views from kitsune.sumo.i18n import i18n_patterns @@ -63,8 +62,6 @@ path("api/1/products/", include("kitsune.products.urls_api")), path("api/1/gallery/", include("kitsune.gallery.urls_api")), path("api/1/users/", include("kitsune.users.urls_api")), - # API to pull wiki metrics data. - re_path(r"^api/v1/wikimetrics/?$", WikiMetricList.as_view(), name="api.wikimetric_list"), # v2 APIs path("api/2/", include("kitsune.notifications.urls_api")), path("api/2/", include("kitsune.questions.urls_api")),