Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 </devel/reporting>` 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.
Expand Down
10 changes: 10 additions & 0 deletions weblate/billing/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,16 @@
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()
Expand Down Expand Up @@ -1155,7 +1165,7 @@
return self.full_filename and os.path.exists(self.full_filename)

def clean(self) -> None:
if self.end is None or self.start is None:

Check failure on line 1168 in weblate/billing/models.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible return value type (got "Literal[''] | bool | None", expected "bool")
return

if self.end <= self.start:
Expand Down Expand Up @@ -1275,7 +1285,7 @@
def get_project_billings(
project: Project, *, using=None, include_previous=False
) -> QuerySet[Billing, Billing]:
objects = Billing.objects

Check failure on line 1288 in weblate/billing/models.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "Any | Deferred", variable has type "UUID | None")
if using is not None:
objects = objects.db_manager(using)
workspace_ids: set[UUID] = set()
Expand Down
10 changes: 9 additions & 1 deletion weblate/billing/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,10 @@
msg = f"File {invoice.filename} does not exist!"
raise Http404(msg)

return FileResponse(

Check failure on line 104 in weblate/billing/views.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible return value type (got "FileResponse", expected "HttpResponse")
open(filename, "rb"),
as_attachment=True,
filename=invoice.filename,

Check failure on line 107 in weblate/billing/views.py

View workflow job for this annotation

GitHub Actions / mypy

Argument "filename" to "FileResponse" has incompatible type "str | None"; expected "str"
content_type="application/pdf",
)

Expand All @@ -124,7 +124,7 @@
if not backup_path.exists():
msg = "File not found!"
raise Http404(msg)
return FileResponse(

Check failure on line 127 in weblate/billing/views.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible return value type (got "FileResponse", expected "HttpResponse")
backup_path.open("rb"),
as_attachment=True,
filename=backup_path.name,
Expand Down Expand Up @@ -214,7 +214,7 @@
event=BillingEvent.LIBRE_APPROVED, user=request.user
)
elif "request" in request.POST and billing.is_libre_trial:
form = HostingForm(request.POST)

Check failure on line 217 in weblate/billing/views.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "HostingForm", variable has type "BillingPlanChangeForm")
if form.is_valid():
project = billing.get_projects_queryset().get()
subject = f"Hosting request for {project}"
Expand All @@ -237,7 +237,7 @@
"billing_url": billing.get_absolute_url(),
},
name=request.user.get_visible_name(),
email=request.user.email,

Check failure on line 240 in weblate/billing/views.py

View workflow job for this annotation

GitHub Actions / mypy

Argument "email" to "mail_admins_contact" has incompatible type "str | None"; expected "str"
to=settings.ADMINS_HOSTING,
)
else:
Expand Down Expand Up @@ -307,7 +307,15 @@
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)
Expand Down
62 changes: 61 additions & 1 deletion weblate/metrics/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
ProjectLanguage,
prefetch_stats,
)
from weblate.workspaces.models import Workspace

if TYPE_CHECKING:
from weblate.trans.models.change import ChangeQuerySet
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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(),
Comment thread
nijel marked this conversation as resolved.
"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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions weblate/metrics/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
)
from weblate.utils.celery import app
from weblate.utils.stats import iter_prefetch_stats
from weblate.workspaces.models import Workspace


@app.task(trail=False)
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()):
Expand Down
3 changes: 3 additions & 0 deletions weblate/metrics/templatetags/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Translation,
)
from weblate.utils.stats import CategoryLanguage, ProjectLanguage
from weblate.workspaces.models import Workspace

register = template.Library()

Expand All @@ -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):
Expand Down
38 changes: 38 additions & 0 deletions weblate/metrics/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 2 additions & 41 deletions weblate/templates/manage/workspaces.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{% extends "base.html" %}

{% load humanize i18n %}
{% load i18n %}

{% block breadcrumbs %}
<li class="breadcrumb-item">
Expand Down Expand Up @@ -32,47 +32,8 @@ <h4 class="card-title">{% translate "Manage workspaces" %}</h4>
</div>
{% if search_form.q.errors %}<div class="invalid-feedback d-block">{{ search_form.q.errors }}</div>{% endif %}
</form>
{% include "snippets/list-objects.html" with objects=object_list label=_("Workspace") show_workspace_details=True empty_message=_("No workspaces found.") %}
</div>
<table class="table table-striped">
<thead>
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Projects" %}</th>
{% if billing_enabled %}
<th>{% translate "Billing" %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for workspace in object_list %}
<tr>
<td>
<a href="{{ workspace.get_absolute_url }}">{{ workspace }}</a>
</td>
<td class="number">{{ workspace.projects__count|intcomma }}</td>
{% if billing_enabled %}
<td>
{% if workspace.billing %}
<a href="{{ workspace.billing.get_absolute_url }}">
{% if workspace.billing.customer_name %}
{{ workspace.billing.customer_name }}
{% else %}
{% blocktranslate with id=workspace.billing.pk %}Billing #{{ id }}{% endblocktranslate %}
{% endif %}
</a>
{% else %}
{% translate "Not available" %}
{% endif %}
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if billing_enabled %}3{% else %}2{% endif %}">{% translate "No workspaces found." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card-footer">{% include "paginator.html" %}</div>
</div>

Expand Down
32 changes: 30 additions & 2 deletions weblate/templates/snippets/info.html
Original file line number Diff line number Diff line change
Expand Up @@ -483,12 +483,40 @@ <h4 class="card-title">{% translate "Quick numbers" %}</h4>
</div>
<div class="col-sm-3">
<div class="metric">
<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 %}">
{% if not workspace %}
<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 %}">
{% endif %}
<div class="metric-number">{{ metrics.contributors_total|number_format }}</div>
<div class="metric-text">{% translate "Contributors" %}</div>
</a>
{% if not workspace %}</a>{% endif %}
</div>
</div>
{% if workspace %}
<div class="col-sm-3">
<div class="metric">
<div class="metric-number">{{ metrics.projects|number_format }}</div>
<div class="metric-text">{% translate "Projects" %}</div>
</div>
</div>
<div class="col-sm-3">
<div class="metric">
<div class="metric-number">{{ metrics.components|number_format }}</div>
<div class="metric-text">{% translate "Components" %}</div>
</div>
</div>
<div class="col-sm-3">
<div class="metric">
<div class="metric-number">{{ metrics.languages|number_format }}</div>
<div class="metric-text">{% translate "Languages" %}</div>
</div>
</div>
<div class="col-sm-3">
<div class="metric">
<div class="metric-number">{{ metrics.translations|number_format }}</div>
<div class="metric-text">{% translate "Translations" %}</div>
</div>
</div>
{% endif %}
{% if project and not language %}
<div class="col-sm-3">
<div class="metric">
Expand Down
Loading
Loading