From 0d3a8ae55cf0980b9cc3270eefc3d480ee491f6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Mon, 20 Jul 2026 20:53:32 +0200 Subject: [PATCH] feat(workspaces): add statistics and metrics Expose aggregate translation health and historical trends at workspace scope. Keep metric lookups integer-indexed and batch the management listing to avoid per-workspace queries. --- docs/changes.rst | 1 + weblate/billing/models.py | 10 ++ weblate/billing/views.py | 10 +- weblate/metrics/models.py | 62 ++++++++- weblate/metrics/tasks.py | 3 + weblate/metrics/templatetags/metrics.py | 3 + weblate/metrics/tests.py | 38 ++++++ weblate/templates/manage/workspaces.html | 43 +----- weblate/templates/snippets/info.html | 32 ++++- weblate/templates/snippets/list-objects.html | 50 +++++-- weblate/templates/workspace.html | 9 +- weblate/trans/mixins.py | 4 +- weblate/trans/models/project.py | 14 ++ weblate/trans/templatetags/translations.py | 4 + weblate/utils/stats.py | 54 +++++++- weblate/utils/tasks.py | 22 +++- weblate/wladmin/tests.py | 33 ++++- weblate/wladmin/views.py | 88 ++++++++++++- .../migrations/0004_workspace_metric_id.py | 32 +++++ weblate/workspaces/models.py | 23 +++- weblate/workspaces/tests.py | 123 +++++++++++++++++- 21 files changed, 586 insertions(+), 72 deletions(-) create mode 100644 weblate/workspaces/migrations/0004_workspace_metric_id.py diff --git a/docs/changes.rst b/docs/changes.rst index 8db367bcaee9..a041dad9096b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,7 @@ Weblate 2026.8 .. rubric:: New features +* Workspaces now provide aggregate translation statistics and historical metrics. * Added API endpoints for listing, adding, accepting, rejecting, and voting on translation suggestions. * :doc:`Translation reports ` are now generated in the background, stored for later download, available at workspace scope, and include translator work analysis. * Added :guilabel:`Use keywords exclusively` option to :ref:`addon-weblate.gettext.xgettext`, allowing projects to disable xgettext default keywords and rely only on a custom keyword. diff --git a/weblate/billing/models.py b/weblate/billing/models.py index e1f20e1bd11a..b2a28997c3f6 100644 --- a/weblate/billing/models.py +++ b/weblate/billing/models.py @@ -498,6 +498,16 @@ def _set_project_workspace( project_objects = project_objects.db_manager(database) if not project_objects.filter(pk=project.pk).update(workspace=self.workspace): raise Project.DoesNotExist + # ruff: ignore[import-outside-top-level] + from weblate.utils.tasks import update_workspace_stats + + update_workspace_stats.delay_on_commit( + [ + str(workspace_id) + for workspace_id in (previous_workspace_id, self.workspace_id) + if workspace_id is not None + ] + ) project.workspace = self.workspace project.billing_original_workspace_id = self.workspace_id self.update_workspace_name() diff --git a/weblate/billing/views.py b/weblate/billing/views.py index 377a687ee87b..e5e4475579a8 100644 --- a/weblate/billing/views.py +++ b/weblate/billing/views.py @@ -307,7 +307,15 @@ def merge(request: AuthenticatedHttpRequest, pk) -> HttpResponse: if "all" in billing.payment: other.payment.setdefault("all", []).extend(billing.payment["all"]) other.save() - billing.get_projects_queryset().update(workspace=other.workspace) + moved_projects = billing.get_projects_queryset() + projects_moved = moved_projects.update(workspace=other.workspace) + if projects_moved: + # ruff: ignore[import-outside-top-level] + from weblate.utils.tasks import update_workspace_stats + + update_workspace_stats.delay_on_commit( + [str(billing.workspace_id), str(other.workspace_id)] + ) other.update_workspace_name() merge_workspace_access(billing, other) billing.invoice_set.update(billing=other) diff --git a/weblate/metrics/models.py b/weblate/metrics/models.py index f02b83d57f5d..845a4a033802 100644 --- a/weblate/metrics/models.py +++ b/weblate/metrics/models.py @@ -37,6 +37,7 @@ ProjectLanguage, prefetch_stats, ) +from weblate.workspaces.models import Workspace if TYPE_CHECKING: from weblate.trans.models.change import ChangeQuerySet @@ -208,7 +209,8 @@ def calculate_changes( | User | Language | ProjectLanguage - | CategoryLanguage, + | CategoryLanguage + | Workspace, ): changes = cast("ChangeQuerySet", obj.change_set.all()) # type: ignore[misc] elif isinstance(obj, ComponentList): @@ -234,6 +236,8 @@ def collect_auto(self, obj): return self.collect_component(obj) if isinstance(obj, Project): return self.collect_project(obj) + if isinstance(obj, Workspace): + return self.collect_workspace(obj) if isinstance(obj, Category): return self.collect_category(obj) if isinstance(obj, ComponentList): @@ -407,6 +411,44 @@ def collect_project(self, project: Project): data, project.stats, SOURCE_KEYS, Metric.SCOPE_PROJECT, project.pk ) + @transaction.atomic + def collect_workspace(self, workspace: Workspace): + workspace_scope = MemoryScope.objects.filter( + memory_id=OuterRef("pk"), + workspace=workspace, + scope=MemoryScope.SCOPE_WORKSPACE, + ) + changes = workspace.change_set.all() + data = { + "projects": workspace.projects.count(), + "components": Component.objects.filter( + project__workspace=workspace + ).count(), + "translations": Translation.objects.filter( + component__project__workspace=workspace + ).count(), + "memory": Memory.objects.alias(has_workspace_scope=Exists(workspace_scope)) + .filter(has_workspace_scope=True) + .count(), + "screenshots": Screenshot.objects.filter( + translation__component__project__workspace=workspace + ).count(), + "changes": changes.filter_by_day( + timezone.now().date() - datetime.timedelta(days=1) + ).count(), + "contributors": changes.since_day( + timezone.now().date() - datetime.timedelta(days=30) + ).count_users(), + "contributors_total": changes.count_users(), + } + return self.create_metrics( + data, + workspace.stats, + SOURCE_KEYS, + Metric.SCOPE_WORKSPACE, + workspace.metric_id, + ) + @transaction.atomic def collect_component(self, component: Component): data = { @@ -520,6 +562,7 @@ class Metric(models.Model): SCOPE_LANGUAGE = 7 SCOPE_CATEGORY = 8 SCOPE_CATEGORY_LANGUAGE = 9 + SCOPE_WORKSPACE = 10 id = models.BigAutoField(primary_key=True) date = models.DateField(default=datetime.date.today) @@ -559,6 +602,15 @@ def create_metrics_project(sender, instance, created=False, **kwargs) -> None: ) +@receiver(post_save, sender=Workspace) +@disable_for_loaddata +def create_metrics_workspace(sender, instance, created=False, **kwargs) -> None: + if created: + Metric.objects.initialize_metrics( + scope=Metric.SCOPE_WORKSPACE, relation=instance.metric_id + ) + + @receiver(post_save, sender=Category) @disable_for_loaddata def create_metrics_category(sender, instance, created=False, **kwargs) -> None: @@ -602,6 +654,14 @@ def delete_metrics_category(sender, instance, **kwargs) -> None: ).delete() +@receiver(post_delete, sender=Workspace) +@disable_for_loaddata +def delete_metrics_workspace(sender, instance, **kwargs) -> None: + Metric.objects.filter( + scope=Metric.SCOPE_WORKSPACE, relation=instance.metric_id + ).delete() + + @receiver(post_delete, sender=Project) @disable_for_loaddata def delete_metrics_project(sender, instance, **kwargs) -> None: diff --git a/weblate/metrics/tasks.py b/weblate/metrics/tasks.py index b4ea4b27fd50..79df7797f909 100644 --- a/weblate/metrics/tasks.py +++ b/weblate/metrics/tasks.py @@ -19,6 +19,7 @@ ) from weblate.utils.celery import app from weblate.utils.stats import iter_prefetch_stats +from weblate.workspaces.models import Workspace @app.task(trail=False) @@ -26,6 +27,8 @@ def collect_metrics() -> None: Metric.objects.collect_global() for project in iter_prefetch_stats(Project.objects.all()): Metric.objects.collect_project(project) + for workspace in iter_prefetch_stats(Workspace.objects.all()): + Metric.objects.collect_workspace(workspace) for category in iter_prefetch_stats(Category.objects.all()): Metric.objects.collect_category(category) for component in iter_prefetch_stats(Component.objects.all()): diff --git a/weblate/metrics/templatetags/metrics.py b/weblate/metrics/templatetags/metrics.py index c5d8d8cfc6cb..10125eb9a418 100644 --- a/weblate/metrics/templatetags/metrics.py +++ b/weblate/metrics/templatetags/metrics.py @@ -16,6 +16,7 @@ Translation, ) from weblate.utils.stats import CategoryLanguage, ProjectLanguage +from weblate.workspaces.models import Workspace register = template.Library() @@ -30,6 +31,8 @@ def metrics(obj): return MetricsWrapper(obj, Metric.SCOPE_COMPONENT, obj.pk) if isinstance(obj, Project): return MetricsWrapper(obj, Metric.SCOPE_PROJECT, obj.pk) + if isinstance(obj, Workspace): + return MetricsWrapper(obj, Metric.SCOPE_WORKSPACE, obj.metric_id) if isinstance(obj, ComponentList): return MetricsWrapper(obj, Metric.SCOPE_COMPONENT_LIST, obj.pk) if isinstance(obj, ProjectLanguage): diff --git a/weblate/metrics/tests.py b/weblate/metrics/tests.py index df3f70bd38ce..a495aaf0094f 100644 --- a/weblate/metrics/tests.py +++ b/weblate/metrics/tests.py @@ -15,6 +15,7 @@ from weblate.trans.models import Category, ComponentLink, Project from weblate.trans.models.change import Change from weblate.trans.tests.test_views import FixtureComponentTestCase +from weblate.workspaces.models import Workspace class MetricTestCase(FixtureComponentTestCase): @@ -112,6 +113,43 @@ def test_collect_global(self) -> None: Metric.objects.collect_global() self.assertNotEqual(Metric.objects.count(), 0) + def test_collect_workspace(self) -> None: + workspace = Workspace.objects.create(name="Metrics workspace") + self.project.workspace = workspace + self.project.save(update_fields=["workspace"]) + + metric = Metric.objects.collect_workspace(workspace) + + self.assertEqual(metric.scope, Metric.SCOPE_WORKSPACE) + self.assertEqual(metric.relation, workspace.metric_id) + self.assertEqual(metric.dict_data["projects"], 1) + self.assertEqual(metric.dict_data["components"], 1) + self.assertEqual( + metric.dict_data["translations"], + self.component.translation_set.count(), + ) + self.assertEqual(metric.dict_data["all"], self.project.stats.all) + + def test_workspace_metric_lifecycle(self) -> None: + workspace = Workspace.objects.create(name="Metric lifecycle workspace") + relation = workspace.metric_id + + self.assertTrue( + Metric.objects.filter( + scope=Metric.SCOPE_WORKSPACE, + relation=relation, + ).exists() + ) + + workspace.delete() + + self.assertFalse( + Metric.objects.filter( + scope=Metric.SCOPE_WORKSPACE, + relation=relation, + ).exists() + ) + def test_wrapper_prefers_today_metric(self) -> None: today = timezone.now().date() yesterday = today - timedelta(days=1) diff --git a/weblate/templates/manage/workspaces.html b/weblate/templates/manage/workspaces.html index e5b0913fd11f..22923d162e27 100644 --- a/weblate/templates/manage/workspaces.html +++ b/weblate/templates/manage/workspaces.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% load humanize i18n %} +{% load i18n %} {% block breadcrumbs %}