Skip to content

Commit dee2936

Browse files
committed
fix(stats): keep aggregated statistics consistent
Keep statistics correct across shared topology changes and deletions while moving expensive recalculation out of request and cascade paths. Fixes #20771
1 parent 7b177d3 commit dee2936

19 files changed

Lines changed: 1215 additions & 295 deletions

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Weblate 2026.8
3030

3131
.. rubric:: Bug fixes
3232

33+
* Category, project, and comment statistics now stay consistent after component topology and comment changes, and category metrics are collected correctly.
3334
* Mercurial repository filenames beginning with a dash are now handled safely.
3435
* Self-service REST API e-mail changes are now restricted to verified addresses.
3536
* REST API authorization now consistently protects internal accounts, restricted components, add-on configuration, component sharing, repository links, and review states.

weblate/accounts/notifications.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
from weblate.utils.markdown import get_mention_users
4545
from weblate.utils.ratelimit import rate_limit_notify
4646
from weblate.utils.site import get_site_domain, get_site_url
47-
from weblate.utils.stats import prefetch_stats
47+
from weblate.utils.stats import iter_prefetch_stats, prefetch_stats
4848
from weblate.utils.version import USER_AGENT
4949
from weblate.utils.version_display import VERSION_DISPLAY_HIDE
5050

@@ -1348,7 +1348,7 @@ def notify_summary(
13481348
) -> None:
13491349
users = {}
13501350
notifications: dict[int, list[dict[str, Any]]] = defaultdict(list)
1351-
for translation in prefetch_stats(Translation.objects.prefetch()):
1351+
for translation in iter_prefetch_stats(Translation.objects.prefetch()):
13521352
count = self.get_count(translation)
13531353
if not count:
13541354
continue
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright © Michal Čihař <michal@weblate.org>
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
from django.db import migrations
6+
7+
8+
def cleanup_category_language_metrics(apps, schema_editor) -> None:
9+
"""Remove metrics keyed by project IDs instead of category IDs."""
10+
metric = apps.get_model("metrics", "Metric")
11+
metric.objects.filter(scope=9).delete()
12+
13+
14+
class Migration(migrations.Migration):
15+
dependencies = [
16+
("metrics", "0001_squashed_0002_new_public_projects_metric_data"),
17+
]
18+
19+
operations = [
20+
migrations.RunPython(
21+
cleanup_category_language_metrics, migrations.RunPython.noop
22+
),
23+
]

weblate/metrics/models.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from weblate.trans.models import (
2525
Category,
2626
Component,
27+
ComponentLink,
2728
ComponentList,
2829
Project,
2930
Translation,
@@ -298,7 +299,7 @@ def collect_project_language(self, project_language: ProjectLanguage):
298299
@transaction.atomic
299300
def collect_category_language(self, category_language: CategoryLanguage):
300301
category = category_language.category
301-
changes = category.project.change_set.for_category(category).filter(
302+
changes = Change.objects.for_category(category).filter(
302303
translation__language=category_language.language
303304
)
304305

@@ -317,7 +318,7 @@ def collect_category_language(self, category_language: CategoryLanguage):
317318
category_language.stats,
318319
SOURCE_KEYS,
319320
Metric.SCOPE_CATEGORY_LANGUAGE,
320-
category.project.pk,
321+
category.pk,
321322
category_language.language.pk,
322323
)
323324

@@ -329,10 +330,23 @@ def collect_category(self, category: Category):
329330
for category_language in languages:
330331
self.collect_category_language(category_language)
331332
changes = Change.objects.for_category(category)
333+
category_filter = (
334+
Q(category=category)
335+
| Q(category__category=category)
336+
| Q(category__category__category=category)
337+
)
338+
shared_component_ids = ComponentLink.objects.filter(
339+
Q(category=category)
340+
| Q(category__category=category)
341+
| Q(category__category__category=category)
342+
).values_list("component_id", flat=True)
343+
components = Component.objects.filter(
344+
category_filter | Q(pk__in=shared_component_ids)
345+
).distinct()
332346
data = {
333-
"components": category.component_set.count(),
347+
"components": components.count(),
334348
"translations": Translation.objects.filter(
335-
component__category=category
349+
component__in=components
336350
).count(),
337351
"changes": changes.filter_by_day(
338352
timezone.now().date() - datetime.timedelta(days=1)

weblate/metrics/tasks.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,33 @@
1010
from weblate.auth.models import User
1111
from weblate.lang.models import Language
1212
from weblate.metrics.models import Metric
13-
from weblate.trans.models import Component, ComponentList, Project, Translation
13+
from weblate.trans.models import (
14+
Category,
15+
Component,
16+
ComponentList,
17+
Project,
18+
Translation,
19+
)
1420
from weblate.utils.celery import app
15-
from weblate.utils.stats import prefetch_stats
21+
from weblate.utils.stats import iter_prefetch_stats
1622

1723

1824
@app.task(trail=False)
1925
def collect_metrics() -> None:
2026
Metric.objects.collect_global()
21-
for project in prefetch_stats(Project.objects.all()):
27+
for project in iter_prefetch_stats(Project.objects.all()):
2228
Metric.objects.collect_project(project)
23-
for component in prefetch_stats(Component.objects.all()):
29+
for category in iter_prefetch_stats(Category.objects.all()):
30+
Metric.objects.collect_category(category)
31+
for component in iter_prefetch_stats(Component.objects.all()):
2432
Metric.objects.collect_component(component)
25-
for clist in prefetch_stats(ComponentList.objects.all()):
33+
for clist in iter_prefetch_stats(ComponentList.objects.all()):
2634
Metric.objects.collect_component_list(clist)
27-
for translation in prefetch_stats(Translation.objects.all()):
35+
for translation in iter_prefetch_stats(Translation.objects.all()):
2836
Metric.objects.collect_translation(translation)
2937
for user in User.objects.filter():
3038
Metric.objects.collect_user(user)
31-
for language in prefetch_stats(Language.objects.all()):
39+
for language in iter_prefetch_stats(Language.objects.all()):
3240
Metric.objects.collect_language(language)
3341

3442

weblate/metrics/tests.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,111 @@
22
#
33
# SPDX-License-Identifier: GPL-3.0-or-later
44

5+
import importlib
56
from datetime import timedelta
67

8+
from django.apps import apps
79
from django.utils import timezone
810

911
from weblate.metrics.models import Metric
1012
from weblate.metrics.tasks import cleanup_metrics, collect_metrics
1113
from weblate.metrics.wrapper import MetricsWrapper
12-
from weblate.trans.models import Project
14+
from weblate.trans.actions import ActionEvents
15+
from weblate.trans.models import Category, ComponentLink, Project
16+
from weblate.trans.models.change import Change
1317
from weblate.trans.tests.test_views import FixtureComponentTestCase
1418

1519

1620
class MetricTestCase(FixtureComponentTestCase):
1721
def test_collect(self) -> None:
22+
category = Category.objects.create(
23+
project=self.project, name="Metrics", slug="metrics"
24+
)
25+
self.component.category = category
26+
self.component.save(update_fields=["category"])
1827
collect_metrics()
1928
self.assertNotEqual(Metric.objects.count(), 0)
29+
self.assertTrue(
30+
Metric.objects.filter(
31+
scope=Metric.SCOPE_CATEGORY,
32+
relation=category.pk,
33+
data__isnull=False,
34+
).exists()
35+
)
36+
self.assertTrue(
37+
Metric.objects.filter(
38+
scope=Metric.SCOPE_CATEGORY_LANGUAGE,
39+
relation=category.pk,
40+
data__isnull=False,
41+
).exists()
42+
)
43+
44+
def test_collect_nested_shared_category(self) -> None:
45+
Change.objects.all().delete()
46+
other = Project.objects.create(name="Metrics target", slug="metrics-target")
47+
parent = Category.objects.create(
48+
project=other, name="Metrics parent", slug="metrics-parent"
49+
)
50+
child = Category.objects.create(
51+
project=other,
52+
category=parent,
53+
name="Metrics child",
54+
slug="metrics-child",
55+
)
56+
ComponentLink.objects.create(
57+
component=self.component, project=other, category=child
58+
)
59+
change = self.translation.change_set.create(
60+
action=ActionEvents.CHANGE, user=self.user
61+
)
62+
Change.objects.filter(pk=change.pk).update(
63+
timestamp=timezone.now() - timedelta(days=1)
64+
)
65+
Metric.objects.filter(scope=Metric.SCOPE_CATEGORY, relation=parent.pk).delete()
66+
67+
metric = Metric.objects.collect_category(parent)
68+
self.assertEqual(metric.dict_data["components"], 1)
69+
self.assertEqual(
70+
metric.dict_data["translations"], self.component.translation_set.count()
71+
)
72+
self.assertEqual(metric.dict_data["all"], self.component.stats.all)
73+
self.assertEqual(metric.changes, 1)
74+
self.assertEqual(metric.dict_data["contributors"], 1)
75+
self.assertEqual(metric.dict_data["contributors_total"], 1)
76+
for language in parent.languages:
77+
language_metric = Metric.objects.get(
78+
scope=Metric.SCOPE_CATEGORY_LANGUAGE,
79+
relation=parent.pk,
80+
secondary=language.pk,
81+
data__isnull=False,
82+
)
83+
expected = int(language == self.translation.language)
84+
self.assertEqual(language_metric.changes, expected)
85+
self.assertEqual(language_metric.dict_data["contributors"], expected)
86+
self.assertEqual(language_metric.dict_data["contributors_total"], expected)
87+
88+
def test_cleanup_legacy_category_language_metric_keys(self) -> None:
89+
migration = importlib.import_module(
90+
"weblate.metrics.migrations.0003_cleanup_category_language_metric_keys"
91+
)
92+
legacy = Metric.objects.create(
93+
scope=Metric.SCOPE_CATEGORY_LANGUAGE,
94+
relation=self.project.pk,
95+
secondary=self.translation.language_id,
96+
changes=0,
97+
data={},
98+
)
99+
retained = Metric.objects.create(
100+
scope=Metric.SCOPE_PROJECT,
101+
relation=self.project.pk,
102+
changes=0,
103+
data={},
104+
)
105+
106+
migration.cleanup_category_language_metrics(apps, None)
107+
108+
self.assertFalse(Metric.objects.filter(pk=legacy.pk).exists())
109+
self.assertTrue(Metric.objects.filter(pk=retained.pk).exists())
20110

21111
def test_collect_global(self) -> None:
22112
Metric.objects.collect_global()

0 commit comments

Comments
 (0)