Skip to content

Commit f535dea

Browse files
committed
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.
1 parent dee2936 commit f535dea

21 files changed

Lines changed: 574 additions & 71 deletions

File tree

docs/changes.rst

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

66
.. rubric:: New features
77

8+
* Workspaces now provide aggregate translation statistics and historical metrics.
89
* Added API endpoints for listing, adding, accepting, rejecting, and voting on translation suggestions.
910
* :doc:`Translation reports </devel/reporting>` are now generated in the background, stored for later download, available at workspace scope, and include translator work analysis.
1011
* 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.

weblate/billing/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,16 @@ def _set_project_workspace(
498498
project_objects = project_objects.db_manager(database)
499499
if not project_objects.filter(pk=project.pk).update(workspace=self.workspace):
500500
raise Project.DoesNotExist
501+
# ruff: ignore[import-outside-top-level]
502+
from weblate.utils.tasks import update_workspace_stats
503+
504+
update_workspace_stats.delay_on_commit(
505+
[
506+
str(workspace_id)
507+
for workspace_id in (previous_workspace_id, self.workspace_id)
508+
if workspace_id is not None
509+
]
510+
)
501511
project.workspace = self.workspace
502512
project.billing_original_workspace_id = self.workspace_id
503513
self.update_workspace_name()

weblate/billing/views.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,15 @@ def merge(request: AuthenticatedHttpRequest, pk) -> HttpResponse:
307307
if "all" in billing.payment:
308308
other.payment.setdefault("all", []).extend(billing.payment["all"])
309309
other.save()
310-
billing.get_projects_queryset().update(workspace=other.workspace)
310+
moved_projects = billing.get_projects_queryset()
311+
projects_moved = moved_projects.update(workspace=other.workspace)
312+
if projects_moved:
313+
# ruff: ignore[import-outside-top-level]
314+
from weblate.utils.tasks import update_workspace_stats
315+
316+
update_workspace_stats.delay_on_commit(
317+
[str(billing.workspace_id), str(other.workspace_id)]
318+
)
311319
other.update_workspace_name()
312320
merge_workspace_access(billing, other)
313321
billing.invoice_set.update(billing=other)

weblate/metrics/models.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
ProjectLanguage,
3838
prefetch_stats,
3939
)
40+
from weblate.workspaces.models import Workspace
4041

4142
if TYPE_CHECKING:
4243
from weblate.trans.models.change import ChangeQuerySet
@@ -208,7 +209,8 @@ def calculate_changes(
208209
| User
209210
| Language
210211
| ProjectLanguage
211-
| CategoryLanguage,
212+
| CategoryLanguage
213+
| Workspace,
212214
):
213215
changes = cast("ChangeQuerySet", obj.change_set.all()) # type: ignore[misc]
214216
elif isinstance(obj, ComponentList):
@@ -234,6 +236,8 @@ def collect_auto(self, obj):
234236
return self.collect_component(obj)
235237
if isinstance(obj, Project):
236238
return self.collect_project(obj)
239+
if isinstance(obj, Workspace):
240+
return self.collect_workspace(obj)
237241
if isinstance(obj, Category):
238242
return self.collect_category(obj)
239243
if isinstance(obj, ComponentList):
@@ -407,6 +411,44 @@ def collect_project(self, project: Project):
407411
data, project.stats, SOURCE_KEYS, Metric.SCOPE_PROJECT, project.pk
408412
)
409413

414+
@transaction.atomic
415+
def collect_workspace(self, workspace: Workspace):
416+
workspace_scope = MemoryScope.objects.filter(
417+
memory_id=OuterRef("pk"),
418+
workspace=workspace,
419+
scope=MemoryScope.SCOPE_WORKSPACE,
420+
)
421+
changes = workspace.change_set.all()
422+
data = {
423+
"projects": workspace.projects.count(),
424+
"components": Component.objects.filter(
425+
project__workspace=workspace
426+
).count(),
427+
"translations": Translation.objects.filter(
428+
component__project__workspace=workspace
429+
).count(),
430+
"memory": Memory.objects.alias(has_workspace_scope=Exists(workspace_scope))
431+
.filter(has_workspace_scope=True)
432+
.count(),
433+
"screenshots": Screenshot.objects.filter(
434+
translation__component__project__workspace=workspace
435+
).count(),
436+
"changes": changes.filter_by_day(
437+
timezone.now().date() - datetime.timedelta(days=1)
438+
).count(),
439+
"contributors": changes.since_day(
440+
timezone.now().date() - datetime.timedelta(days=30)
441+
).count_users(),
442+
"contributors_total": changes.count_users(),
443+
}
444+
return self.create_metrics(
445+
data,
446+
workspace.stats,
447+
SOURCE_KEYS,
448+
Metric.SCOPE_WORKSPACE,
449+
workspace.metric_id,
450+
)
451+
410452
@transaction.atomic
411453
def collect_component(self, component: Component):
412454
data = {
@@ -520,6 +562,7 @@ class Metric(models.Model):
520562
SCOPE_LANGUAGE = 7
521563
SCOPE_CATEGORY = 8
522564
SCOPE_CATEGORY_LANGUAGE = 9
565+
SCOPE_WORKSPACE = 10
523566

524567
id = models.BigAutoField(primary_key=True)
525568
date = models.DateField(default=datetime.date.today)
@@ -559,6 +602,15 @@ def create_metrics_project(sender, instance, created=False, **kwargs) -> None:
559602
)
560603

561604

605+
@receiver(post_save, sender=Workspace)
606+
@disable_for_loaddata
607+
def create_metrics_workspace(sender, instance, created=False, **kwargs) -> None:
608+
if created:
609+
Metric.objects.initialize_metrics(
610+
scope=Metric.SCOPE_WORKSPACE, relation=instance.metric_id
611+
)
612+
613+
562614
@receiver(post_save, sender=Category)
563615
@disable_for_loaddata
564616
def create_metrics_category(sender, instance, created=False, **kwargs) -> None:
@@ -602,6 +654,14 @@ def delete_metrics_category(sender, instance, **kwargs) -> None:
602654
).delete()
603655

604656

657+
@receiver(post_delete, sender=Workspace)
658+
@disable_for_loaddata
659+
def delete_metrics_workspace(sender, instance, **kwargs) -> None:
660+
Metric.objects.filter(
661+
scope=Metric.SCOPE_WORKSPACE, relation=instance.metric_id
662+
).delete()
663+
664+
605665
@receiver(post_delete, sender=Project)
606666
@disable_for_loaddata
607667
def delete_metrics_project(sender, instance, **kwargs) -> None:

weblate/metrics/tasks.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919
)
2020
from weblate.utils.celery import app
2121
from weblate.utils.stats import iter_prefetch_stats
22+
from weblate.workspaces.models import Workspace
2223

2324

2425
@app.task(trail=False)
2526
def collect_metrics() -> None:
2627
Metric.objects.collect_global()
2728
for project in iter_prefetch_stats(Project.objects.all()):
2829
Metric.objects.collect_project(project)
30+
for workspace in iter_prefetch_stats(Workspace.objects.all()):
31+
Metric.objects.collect_workspace(workspace)
2932
for category in iter_prefetch_stats(Category.objects.all()):
3033
Metric.objects.collect_category(category)
3134
for component in iter_prefetch_stats(Component.objects.all()):

weblate/metrics/templatetags/metrics.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
Translation,
1717
)
1818
from weblate.utils.stats import CategoryLanguage, ProjectLanguage
19+
from weblate.workspaces.models import Workspace
1920

2021
register = template.Library()
2122

@@ -30,6 +31,8 @@ def metrics(obj):
3031
return MetricsWrapper(obj, Metric.SCOPE_COMPONENT, obj.pk)
3132
if isinstance(obj, Project):
3233
return MetricsWrapper(obj, Metric.SCOPE_PROJECT, obj.pk)
34+
if isinstance(obj, Workspace):
35+
return MetricsWrapper(obj, Metric.SCOPE_WORKSPACE, obj.metric_id)
3336
if isinstance(obj, ComponentList):
3437
return MetricsWrapper(obj, Metric.SCOPE_COMPONENT_LIST, obj.pk)
3538
if isinstance(obj, ProjectLanguage):

weblate/metrics/tests.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from weblate.trans.models import Category, ComponentLink, Project
1616
from weblate.trans.models.change import Change
1717
from weblate.trans.tests.test_views import FixtureComponentTestCase
18+
from weblate.workspaces.models import Workspace
1819

1920

2021
class MetricTestCase(FixtureComponentTestCase):
@@ -112,6 +113,43 @@ def test_collect_global(self) -> None:
112113
Metric.objects.collect_global()
113114
self.assertNotEqual(Metric.objects.count(), 0)
114115

116+
def test_collect_workspace(self) -> None:
117+
workspace = Workspace.objects.create(name="Metrics workspace")
118+
self.project.workspace = workspace
119+
self.project.save(update_fields=["workspace"])
120+
121+
metric = Metric.objects.collect_workspace(workspace)
122+
123+
self.assertEqual(metric.scope, Metric.SCOPE_WORKSPACE)
124+
self.assertEqual(metric.relation, workspace.metric_id)
125+
self.assertEqual(metric.dict_data["projects"], 1)
126+
self.assertEqual(metric.dict_data["components"], 1)
127+
self.assertEqual(
128+
metric.dict_data["translations"],
129+
self.component.translation_set.count(),
130+
)
131+
self.assertEqual(metric.dict_data["all"], self.project.stats.all)
132+
133+
def test_workspace_metric_lifecycle(self) -> None:
134+
workspace = Workspace.objects.create(name="Metric lifecycle workspace")
135+
relation = workspace.metric_id
136+
137+
self.assertTrue(
138+
Metric.objects.filter(
139+
scope=Metric.SCOPE_WORKSPACE,
140+
relation=relation,
141+
).exists()
142+
)
143+
144+
workspace.delete()
145+
146+
self.assertFalse(
147+
Metric.objects.filter(
148+
scope=Metric.SCOPE_WORKSPACE,
149+
relation=relation,
150+
).exists()
151+
)
152+
115153
def test_wrapper_prefers_today_metric(self) -> None:
116154
today = timezone.now().date()
117155
yesterday = today - timedelta(days=1)

weblate/templates/manage/workspaces.html

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{% extends "base.html" %}
22

3-
{% load humanize i18n %}
3+
{% load i18n %}
44

55
{% block breadcrumbs %}
66
<li class="breadcrumb-item">
@@ -32,47 +32,8 @@ <h4 class="card-title">{% translate "Manage workspaces" %}</h4>
3232
</div>
3333
{% if search_form.q.errors %}<div class="invalid-feedback d-block">{{ search_form.q.errors }}</div>{% endif %}
3434
</form>
35+
{% include "snippets/list-objects.html" with objects=object_list label=_("Workspace") show_workspace_details=True empty_message=_("No workspaces found.") %}
3536
</div>
36-
<table class="table table-striped">
37-
<thead>
38-
<tr>
39-
<th>{% translate "Name" %}</th>
40-
<th>{% translate "Projects" %}</th>
41-
{% if billing_enabled %}
42-
<th>{% translate "Billing" %}</th>
43-
{% endif %}
44-
</tr>
45-
</thead>
46-
<tbody>
47-
{% for workspace in object_list %}
48-
<tr>
49-
<td>
50-
<a href="{{ workspace.get_absolute_url }}">{{ workspace }}</a>
51-
</td>
52-
<td class="number">{{ workspace.projects__count|intcomma }}</td>
53-
{% if billing_enabled %}
54-
<td>
55-
{% if workspace.billing %}
56-
<a href="{{ workspace.billing.get_absolute_url }}">
57-
{% if workspace.billing.customer_name %}
58-
{{ workspace.billing.customer_name }}
59-
{% else %}
60-
{% blocktranslate with id=workspace.billing.pk %}Billing #{{ id }}{% endblocktranslate %}
61-
{% endif %}
62-
</a>
63-
{% else %}
64-
{% translate "Not available" %}
65-
{% endif %}
66-
</td>
67-
{% endif %}
68-
</tr>
69-
{% empty %}
70-
<tr>
71-
<td colspan="{% if billing_enabled %}3{% else %}2{% endif %}">{% translate "No workspaces found." %}</td>
72-
</tr>
73-
{% endfor %}
74-
</tbody>
75-
</table>
7637
<div class="card-footer">{% include "paginator.html" %}</div>
7738
</div>
7839

weblate/templates/snippets/info.html

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -483,12 +483,40 @@ <h4 class="card-title">{% translate "Quick numbers" %}</h4>
483483
</div>
484484
<div class="col-sm-3">
485485
<div class="metric">
486-
<a href="{% url "user_list" %}?q={% if language %}translates:{{ language.code|urlencode }}{% elif translation %}translates:{{ translation.language.code|urlencode }}{% endif %} {% if component %}contributes:{{ component.full_slug|urlencode }}{% elif project %}contributes:{{ project.slug|urlencode }}{% endif %}">
486+
{% if not workspace %}
487+
<a href="{% url "user_list" %}?q={% if language %}translates:{{ language.code|urlencode }}{% elif translation %}translates:{{ translation.language.code|urlencode }}{% endif %} {% if component %}contributes:{{ component.full_slug|urlencode }}{% elif project %}contributes:{{ project.slug|urlencode }}{% endif %}">
488+
{% endif %}
487489
<div class="metric-number">{{ metrics.contributors_total|number_format }}</div>
488490
<div class="metric-text">{% translate "Contributors" %}</div>
489-
</a>
491+
{% if not workspace %}</a>{% endif %}
490492
</div>
491493
</div>
494+
{% if workspace %}
495+
<div class="col-sm-3">
496+
<div class="metric">
497+
<div class="metric-number">{{ metrics.projects|number_format }}</div>
498+
<div class="metric-text">{% translate "Projects" %}</div>
499+
</div>
500+
</div>
501+
<div class="col-sm-3">
502+
<div class="metric">
503+
<div class="metric-number">{{ metrics.components|number_format }}</div>
504+
<div class="metric-text">{% translate "Components" %}</div>
505+
</div>
506+
</div>
507+
<div class="col-sm-3">
508+
<div class="metric">
509+
<div class="metric-number">{{ metrics.languages|number_format }}</div>
510+
<div class="metric-text">{% translate "Languages" %}</div>
511+
</div>
512+
</div>
513+
<div class="col-sm-3">
514+
<div class="metric">
515+
<div class="metric-number">{{ metrics.translations|number_format }}</div>
516+
<div class="metric-text">{% translate "Translations" %}</div>
517+
</div>
518+
</div>
519+
{% endif %}
492520
{% if project and not language %}
493521
<div class="col-sm-3">
494522
<div class="metric">

0 commit comments

Comments
 (0)