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/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ Users
"secondary_in_zen": true,
"hide_source_secondary": false,
"wide_tables": false,
"listing_columns": ["untranslated", "untranslated_words", "untranslated_chars", "nottranslated", "checks", "suggestions", "comments"],
"editor_link": "",
"translate_mode": 0,
"zen_mode": 0,
Expand Down
3 changes: 3 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Weblate 2026.9
* Deployment checks and the performance report now detect slow filesystem metadata access in data and cache directories.
* Clarified the instance-wide impact of roles containing site-wide permissions, including :ref:`site-wide user management <site-wide-user-management>`.
* Improved translation file loading performance for metadata-only string changes.
* Added a :guilabel:`Visible columns in lists` preference to choose which statistics columns are shown in project, component, and language lists. See :ref:`user-profile`.
* Reduced :ref:`Celery <celery>` worker startup memory usage by avoiding duplicate Django system checks and loading font rendering only when needed.
* VCS command versions are now validated by configuration health checks instead of during every process startup.
* Billing audit logs now identify users who change plans, initiate payments, or merge billings.
Expand All @@ -23,6 +24,7 @@ Weblate 2026.9

.. rubric:: Bug fixes

* Project backup restores now preserve all project, category, and component settings.
* Daily metric collection now uses independent tasks and more efficient database queries to reduce peak memory usage and avoid losing all scopes when one collection fails.
* Database dump failures are now shown in the backups management interface.
* Project administrators can no longer remove API tokens belonging to other projects.
Expand All @@ -35,6 +37,7 @@ Weblate 2026.9
* Webhook target matching no longer falls back to host/path suffix matching. Component repository URLs must match a repository URL from the webhook payload. See :ref:`hooks-target-matching`.
* Component and category removal now preserves automatically generated translation memory by default. See :ref:`translation-memory` for the optional cleanup behavior.
* Mercurial and Subversion repository hosts can now be trusted using :setting:`VCS_PRIVATE_ALLOWLIST` without restricting Git to the same hosts through :setting:`VCS_ALLOW_HOSTS`.
* The project deletion REST API endpoint now returns ``202 Accepted`` instead of ``204 No Content`` and contains a task URL in the response to track asynchronous deletion progress.

.. rubric:: Upgrading

Expand Down
53 changes: 51 additions & 2 deletions docs/specs/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions docs/user/profile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ components, projects, …) when the browser window is too narrow to fit them.
Enable :guilabel:`Show all columns in lists using horizontal scrolling` to keep
all columns instead and scroll the table horizontally when needed.

Use :guilabel:`Visible columns in lists` to choose which statistics columns
are shown in the listings. This way you can hide numbers you are not
interested in, or make room for additional ones such as :guilabel:`Total
strings`.

Default dashboard view
++++++++++++++++++++++

Expand Down
11 changes: 10 additions & 1 deletion weblate/accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

from weblate.accounts.auth import try_get_user
from weblate.accounts.captcha import MathCaptcha
from weblate.accounts.models import AuditLog, Profile
from weblate.accounts.models import LISTING_COLUMN_CHOICES, AuditLog, Profile
from weblate.accounts.notifications import NOTIFICATIONS, NotificationScope
from weblate.accounts.utils import (
adjust_session_expiry,
Expand Down Expand Up @@ -310,6 +310,14 @@
class UserSettingsForm(ProfileBaseForm):
"""User settings form."""

listing_columns = forms.MultipleChoiceField(
label=Profile._meta.get_field("listing_columns").verbose_name, # ruff: ignore[private-member-access]
help_text=Profile._meta.get_field("listing_columns").help_text, # ruff: ignore[private-member-access]
choices=LISTING_COLUMN_CHOICES,
widget=forms.CheckboxSelectMultiple,
required=False,
)

class Meta:
model = Profile
fields = (
Expand All @@ -321,6 +329,7 @@
"secondary_in_zen",
"hide_source_secondary",
"wide_tables",
"listing_columns",
"editor_link",
"special_chars",
"contribute_personal_tm",
Expand All @@ -328,7 +337,7 @@

def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.fields["special_chars"].strip = False

Check failure on line 340 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

"Field" has no attribute "strip"
self.helper = FormHelper(self)
self.helper.disable_csrf = True
self.helper.form_tag = False
Expand Down Expand Up @@ -551,7 +560,7 @@
self["captcha"].label = cast("str", self.fields["captcha"].label)

def store_challenge(self) -> None:
self.request.session["captcha_challenge"] = self.challenge.signature

Check failure on line 563 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

Item "None" of "Challenge | None" has no attribute "signature"

def clean_captcha(self) -> None:
"""Validate math captcha."""
Expand Down Expand Up @@ -741,7 +750,7 @@

@transaction.atomic
# pylint: disable-next=arguments-renamed
def save(self, request: AuthenticatedHttpRequest, delete_session=False) -> None:

Check failure on line 753 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

Signature of "save" incompatible with supertype "django.contrib.auth.forms.SetPasswordForm"
AuditLog.objects.create(
self.user,
request,
Expand Down Expand Up @@ -1324,7 +1333,7 @@


class TOTPTokenForm(OTPTokenForm):
otp_token = forms.IntegerField(

Check failure on line 1336 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "IntegerField", base class "OTPTokenForm" defined the type as "CharField")
label=gettext_lazy("Enter the code from the app"),
min_value=0,
max_value=999999,
Expand Down
27 changes: 27 additions & 0 deletions weblate/accounts/migrations/0035_profile_listing_columns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copyright © Michal Čihař <michal@weblate.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

from django.db import migrations, models

import weblate.accounts.models


class Migration(migrations.Migration):
dependencies = [
("accounts", "0034_profile_wide_tables"),
]

operations = [
migrations.AddField(
model_name="profile",
name="listing_columns",
field=models.JSONField(
blank=True,
default=weblate.accounts.models.get_default_listing_columns,
help_text="Choose which statistics columns are shown in project, component, and language lists.",
validators=[weblate.accounts.models.validate_listing_columns],
verbose_name="Visible columns in lists",
),
),
]
45 changes: 45 additions & 0 deletions weblate/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@

def should_notify(self) -> bool:
return (
self.user is not None

Check failure on line 662 in weblate/accounts/models.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible return value type (got "Literal[''] | bool | None", expected "bool")
and not self.user.is_bot
and self.user.is_active
and self.user.email
Expand Down Expand Up @@ -743,6 +743,40 @@
return self.social.provider


LISTING_COLUMN_CHOICES = (
("total", gettext_lazy("Total strings")),
("untranslated", gettext_lazy("Unfinished strings")),
("untranslated_words", gettext_lazy("Unfinished words")),
("untranslated_chars", gettext_lazy("Unfinished characters")),
("nottranslated", gettext_lazy("Untranslated strings")),
("checks", gettext_lazy("Checks")),
("suggestions", gettext_lazy("Suggestions")),
("comments", gettext_lazy("Comments")),
)

DEFAULT_LISTING_COLUMNS = (
"untranslated",
"untranslated_words",
"untranslated_chars",
"nottranslated",
"checks",
"suggestions",
"comments",
)


def get_default_listing_columns() -> list[str]:
return list(DEFAULT_LISTING_COLUMNS)


def validate_listing_columns(value) -> None:
valid_columns = {column for column, _name in LISTING_COLUMN_CHOICES}
if not isinstance(value, list) or any(
column not in valid_columns for column in value
):
raise ValidationError(gettext("Invalid listing column selection."))


class Profile(models.Model):
"""User profiles storage."""

Expand Down Expand Up @@ -806,6 +840,16 @@
"scroll the table horizontally."
),
)
listing_columns = models.JSONField(
verbose_name=gettext_lazy("Visible columns in lists"),
default=get_default_listing_columns,
blank=True,
validators=[validate_listing_columns],
help_text=gettext_lazy(
"Choose which statistics columns are shown in project, component, "
"and language lists."
),
)
editor_link = models.CharField(
default="",
blank=True,
Expand Down Expand Up @@ -1128,6 +1172,7 @@
"secondary_in_zen",
"hide_source_secondary",
"wide_tables",
"listing_columns",
"editor_link",
"translate_mode",
"zen_mode",
Expand Down Expand Up @@ -1298,7 +1343,7 @@
):
email = self.get_site_commit_email()
if not email:
email = self.user.email

Check failure on line 1346 in weblate/accounts/models.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "str | None", variable has type "str")
return email

def get_site_commit_email(self) -> str:
Expand Down Expand Up @@ -1334,7 +1379,7 @@
]
if site_name:
name_choices.append((Profile.CommitNameChoices.PRIVATE, site_name))
return name_choices

Check failure on line 1382 in weblate/accounts/models.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible return value type (got "list[tuple[CommitNameChoices, Reversible[Any]]]", expected "list[tuple[CommitNameChoices, str | _StrPromise]]")

def get_commit_email_choices(self) -> list[tuple[str, StrOrPromise]]:
# ruff: ignore[import-outside-top-level]
Expand All @@ -1345,12 +1390,12 @@

if site_commit_email := self.get_site_commit_email():
if not settings.PRIVATE_COMMIT_EMAIL_OPT_IN:
choices = [("", site_commit_email)]

Check failure on line 1393 in weblate/accounts/models.py

View workflow job for this annotation

GitHub Actions / mypy

List item 0 has incompatible type "tuple[str, str]"; expected "tuple[str, _StrPromise]"
else:
commit_emails.add(site_commit_email)

choices.extend((x, x) for x in sorted(commit_emails))
return choices

Check failure on line 1398 in weblate/accounts/models.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible return value type (got "list[tuple[str, _StrPromise]]", expected "list[tuple[str, str | _StrPromise]]")

def get_public_email_choices(self) -> list[tuple[str, StrOrPromise]]:
# ruff: ignore[import-outside-top-level]
Expand All @@ -1363,7 +1408,7 @@
(x, x)
for x in sorted(get_all_user_mails(self.user, filter_deliverable=True))
)
return choices

Check failure on line 1411 in weblate/accounts/models.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible return value type (got "list[tuple[str, _StrPromise]]", expected "list[tuple[str, str | _StrPromise]]")

def get_site_commit_name(self) -> str:
"""Return the generated private commit name from the site template."""
Expand Down
4 changes: 4 additions & 0 deletions weblate/accounts/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,7 @@ def test_profile(self) -> None:
"nearby_strings": 10,
"theme": "auto",
"wide_tables": "on",
"listing_columns": ["total", "untranslated", "checks"],
"notifications__0-scope": 0,
"notifications__0-project": "",
"notifications__0-component": "",
Expand All @@ -786,6 +787,9 @@ def test_profile(self) -> None:
self.assertRedirects(response, reverse("profile"))
self.user.profile.refresh_from_db()
self.assertTrue(self.user.profile.wide_tables)
self.assertEqual(
self.user.profile.listing_columns, ["total", "untranslated", "checks"]
)

def test_profile_group_display_uses_scoped_team_queryset(self) -> None:
workspace = Workspace.objects.create(name="Profile workspace")
Expand Down
9 changes: 8 additions & 1 deletion weblate/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from rest_framework.exceptions import PermissionDenied
from rest_framework.reverse import reverse

from weblate.accounts.models import Profile, Subscription
from weblate.accounts.models import LISTING_COLUMN_CHOICES, Profile, Subscription
from weblate.accounts.utils import get_all_user_mails
from weblate.addons.base import is_public_addon_change_details
from weblate.addons.models import ADDONS, Addon
Expand Down Expand Up @@ -838,6 +838,12 @@ class ProfileSerializer(serializers.ModelSerializer[Profile]):
commit_email = ProfileEmailChoiceField()
public_email = ProfileEmailChoiceField()
commit_name = ProfileCommitNameChoiceField()
listing_columns = serializers.ListField(
child=serializers.ChoiceField(choices=LISTING_COLUMN_CHOICES),
required=False,
label=Profile._meta.get_field("listing_columns").verbose_name, # ruff: ignore[private-member-access]
help_text=Profile._meta.get_field("listing_columns").help_text, # ruff: ignore[private-member-access]
)

class Meta:
model = Profile
Expand All @@ -854,6 +860,7 @@ class Meta:
"secondary_in_zen",
"hide_source_secondary",
"wide_tables",
"listing_columns",
"editor_link",
"translate_mode",
"zen_mode",
Expand Down
28 changes: 26 additions & 2 deletions weblate/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1554,6 +1554,7 @@ def user_details_kwargs(**kwargs):
"nearby_strings": 5,
"special_chars": "\xa0",
"wide_tables": True,
"listing_columns": ["total", "untranslated", "checks"],
}
},
)
Expand All @@ -1562,6 +1563,9 @@ def user_details_kwargs(**kwargs):
self.assertEqual(self.user.profile.location, "Prague")
self.assertEqual(self.user.profile.nearby_strings, 5)
self.assertTrue(self.user.profile.wide_tables)
self.assertEqual(
self.user.profile.listing_columns, ["total", "untranslated", "checks"]
)
self.assertEqual(
list(self.user.profile.languages.values_list("code", flat=True)),
[cs_language.code],
Expand Down Expand Up @@ -3991,13 +3995,15 @@ def test_delete(self) -> None:
self.do_request(
"api:project-detail", self.project_kwargs, method="delete", code=403
)
self.do_request(
response = self.do_request(
"api:project-detail",
self.project_kwargs,
method="delete",
superuser=True,
code=204,
code=202,
)
self.assertEqual(response.data["detail"], "Project deletion scheduled.")
self.assertIn("task_url", response.data)
self.assertEqual(Project.objects.count(), 0)

def test_create(self) -> None:
Expand Down Expand Up @@ -17081,6 +17087,24 @@ def test_addon_trigger_schema_matches_runtime_behavior(self) -> None:
["detail", "logs_url", "url"],
)

def test_project_delete_schema_matches_runtime_behavior(self) -> None:
schema = self.get_schema()
operation = schema["paths"]["/api/projects/{slug}/"]["delete"]

self.assertNotIn("204", operation["responses"])
self.assertIn("202", operation["responses"])

response_schema = operation["responses"]["202"]["content"]["application/json"][
"schema"
]
self.assertEqual(
response_schema, {"$ref": "#/components/schemas/ProjectDeleteResponse"}
)
self.assertEqual(
schema["components"]["schemas"]["ProjectDeleteResponse"]["required"],
["detail", "task_url"],
)

@patch("weblate.utils.version.VERSION", "5.17.1")
def test_view_uses_latest_docs_links(self) -> None:
response = self.do_request("api-schema")
Expand Down
Loading
Loading