Skip to content

Commit e9b799e

Browse files
committed
fix(backups): preserve all configurable settings
Include every non-secret project, category, and component setting in project backups, and guard against silently omitting settings added in the future.
1 parent f1dfc41 commit e9b799e

3 files changed

Lines changed: 145 additions & 9 deletions

File tree

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Weblate 2026.9
2424

2525
.. rubric:: Bug fixes
2626

27+
* Project backup restores now preserve all project, category, and component settings.
2728
* 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.
2829
* Database dump failures are now shown in the backups management interface.
2930
* Project administrators can no longer remove API tokens belonging to other projects.

weblate/trans/backups.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,26 @@
106106
ModelT = TypeVar("ModelT", bound="Model")
107107
PROJECTBACKUP_PREFIX = "projectbackups"
108108
BackupValue = str | int | bool | dict[str, Any] | list[Any] | None
109-
PROJECT_INHERITABLE_BACKUP_FIELDS = (
109+
PROJECT_BACKUP_FIELDS = (
110+
"use_workspace_tm",
111+
"contribute_workspace_tm",
112+
"autoclean_tm",
113+
"enforced_2fa",
114+
"commit_policy",
110115
"check_flags",
111116
*INHERITABLE_COMPONENT_SETTINGS,
112117
*INHERITABLE_COMPONENT_FLAGS,
113118
)
114-
COMPONENT_INHERITABLE_BACKUP_FIELDS = (
119+
COMPONENT_BACKUP_FIELDS = (
120+
"hide_glossary_matches",
121+
"contribute_project_tm",
122+
"file_format_params",
123+
"screenshot_filemask",
124+
"key_filter",
115125
"secondary_language",
116126
*INHERITABLE_COMPONENT_FLAGS,
117127
)
118-
CATEGORY_INHERITABLE_BACKUP_FIELDS = (
128+
CATEGORY_BACKUP_FIELDS = (
119129
"check_flags",
120130
*INHERITABLE_COMPONENT_SETTINGS,
121131
*INHERITABLE_COMPONENT_FLAGS,
@@ -627,7 +637,7 @@ def backup_categories(
627637
categories = obj.category_set.all()
628638
category_fields = self.extend_fields(
629639
self.project_schema["definitions"]["category"]["required"],
630-
*CATEGORY_INHERITABLE_BACKUP_FIELDS,
640+
*CATEGORY_BACKUP_FIELDS,
631641
)
632642
return [
633643
self.backup_object(
@@ -642,9 +652,7 @@ def backup_data(self, project: Project) -> None:
642652
self.project = project
643653
project_fields = self.extend_fields(
644654
self.project_schema["properties"]["project"]["required"],
645-
"use_workspace_tm",
646-
"contribute_workspace_tm",
647-
*PROJECT_INHERITABLE_BACKUP_FIELDS,
655+
*PROJECT_BACKUP_FIELDS,
648656
)
649657
project_extras: dict[str, Callable[[Project], object]] = {
650658
field: partial(Project.get_effective_setting, field=field)
@@ -720,7 +728,7 @@ def relative_filename(self) -> str:
720728
def backup_component(self, backupzip: ZipFile, component: Component) -> None:
721729
component_fields = self.extend_fields(
722730
self.component_schema["properties"]["component"]["required"],
723-
*COMPONENT_INHERITABLE_BACKUP_FIELDS,
731+
*COMPONENT_BACKUP_FIELDS,
724732
)
725733
data: dict = {
726734
"component": self.backup_object(

weblate/trans/tests/test_backups.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from django.core.files import File
2121
from django.core.files.uploadedfile import SimpleUploadedFile
2222
from django.core.management import call_command
23-
from django.test import override_settings
23+
from django.test import SimpleTestCase, override_settings
2424
from django.urls import reverse
2525

2626
from weblate.addons.webhooks import WebhookAddon
@@ -33,12 +33,20 @@
3333
from weblate.screenshots.models import Screenshot
3434
from weblate.trans.actions import ActionEvents
3535
from weblate.trans.backups import (
36+
CATEGORY_BACKUP_FIELDS,
37+
COMPONENT_BACKUP_FIELDS,
38+
PROJECT_BACKUP_FIELDS,
3639
ProjectBackup,
3740
get_project_backup_download_storage,
3841
get_project_backup_download_url,
3942
list_backups,
4043
)
4144
from weblate.trans.change_display import get_change_history_context
45+
from weblate.trans.forms import (
46+
CategorySettingsForm,
47+
ComponentSettingsForm,
48+
ProjectSettingsForm,
49+
)
4250
from weblate.trans.models import (
4351
Category,
4452
Change,
@@ -50,6 +58,7 @@
5058
Unit,
5159
Vote,
5260
)
61+
from weblate.trans.models.project import CommitPolicyChoices
5362
from weblate.trans.tasks import (
5463
cleanup_project_backup_download,
5564
cleanup_project_backups,
@@ -81,6 +90,58 @@ def resolve_private_example(host, *args, **kwargs):
8190
return [(0, 0, 0, "", (address, 443))]
8291

8392

93+
class BackupSettingCoverageTest(SimpleTestCase):
94+
def test_backup_setting_coverage(self) -> None:
95+
"""Ensure newly introduced editable settings are not silently omitted."""
96+
backup = ProjectBackup()
97+
backup_settings = (
98+
# Workspace is selected by the restore caller. Machinery settings can
99+
# contain service credentials and are intentionally not exported.
100+
(
101+
Project,
102+
ProjectSettingsForm,
103+
backup.project_schema["properties"]["project"],
104+
PROJECT_BACKUP_FIELDS,
105+
{"workspace", "machinery_settings"},
106+
),
107+
# Category hierarchy is represented by the backup object graph.
108+
(
109+
Category,
110+
CategorySettingsForm,
111+
backup.project_schema["definitions"]["category"],
112+
CATEGORY_BACKUP_FIELDS,
113+
{"project", "category"},
114+
),
115+
# Component hierarchy is represented by the backup object graph;
116+
# generated and internal repository revisions are not settings.
117+
(
118+
Component,
119+
ComponentSettingsForm,
120+
backup.component_schema["properties"]["component"],
121+
COMPONENT_BACKUP_FIELDS,
122+
{"project", "category", "git_export", "processed_revision"},
123+
),
124+
)
125+
for model, form, schema, extra_fields, excluded_fields in backup_settings:
126+
with self.subTest(model=model.__name__):
127+
backup_fields = set(schema["required"]) | set(extra_fields)
128+
schema_fields = set(schema["properties"])
129+
# ruff: ignore[private-member-access]
130+
editable_fields = {
131+
field.name
132+
for field in model._meta.fields
133+
if field.editable and not field.auto_created
134+
}
135+
136+
self.assertSetEqual(
137+
editable_fields - excluded_fields,
138+
backup_fields & editable_fields,
139+
)
140+
# ruff: ignore[private-member-access]
141+
self.assertLessEqual(set(form._meta.fields), backup_fields)
142+
self.assertLessEqual(backup_fields, schema_fields)
143+
144+
84145
class BackupsTest(ViewTestCase):
85146
CREATE_GLOSSARIES: bool = True
86147

@@ -850,6 +911,72 @@ def test_backup_inherited_settings(self) -> None:
850911
assert isinstance(secondary_language, Language)
851912
self.assertEqual(secondary_language.code, "de")
852913

914+
def test_backup_settings(self) -> None:
915+
project = self.project
916+
project.autoclean_tm = not project.autoclean_tm
917+
project.enforced_2fa = True
918+
project.commit_policy = CommitPolicyChoices.APPROVED_ONLY
919+
project.save(update_fields=["autoclean_tm", "enforced_2fa", "commit_policy"])
920+
component = self.create_po_mono(project=project, name="Backup-settings")
921+
component.hide_glossary_matches = True
922+
component.contribute_project_tm = False
923+
component.file_format_params = {
924+
"po_line_wrap": 65535,
925+
"po_set_language_team": True,
926+
}
927+
component.screenshot_filemask = "screenshots/*.png"
928+
component.key_filter = "^keep"
929+
component.save(
930+
update_fields=[
931+
"hide_glossary_matches",
932+
"contribute_project_tm",
933+
"file_format_params",
934+
"screenshot_filemask",
935+
"key_filter",
936+
]
937+
)
938+
939+
backup = ProjectBackup()
940+
backup.backup_project(project)
941+
with ZipFile(backup.filename, "r") as zipfile:
942+
project_data = json.loads(zipfile.read("weblate-backup.json"))["project"]
943+
component_data = json.loads(
944+
zipfile.read(f"components/{component.slug}.json")
945+
)["component"]
946+
947+
self.assertEqual(project_data["autoclean_tm"], project.autoclean_tm)
948+
self.assertTrue(project_data["enforced_2fa"])
949+
self.assertEqual(
950+
project_data["commit_policy"], CommitPolicyChoices.APPROVED_ONLY
951+
)
952+
self.assertTrue(component_data["hide_glossary_matches"])
953+
self.assertFalse(component_data["contribute_project_tm"])
954+
self.assertEqual(
955+
component_data["file_format_params"], component.file_format_params
956+
)
957+
self.assertEqual(component_data["screenshot_filemask"], "screenshots/*.png")
958+
self.assertEqual(component_data["key_filter"], "^keep")
959+
960+
restore = ProjectBackup(backup.filename)
961+
restore.validate()
962+
restored = restore.restore(
963+
project_name="Restored settings",
964+
project_slug="restored-settings",
965+
user=self.user,
966+
)
967+
restored_component = restored.component_set.get(slug=component.slug)
968+
969+
self.assertEqual(restored.autoclean_tm, project.autoclean_tm)
970+
self.assertTrue(restored.enforced_2fa)
971+
self.assertEqual(restored.commit_policy, CommitPolicyChoices.APPROVED_ONLY)
972+
self.assertTrue(restored_component.hide_glossary_matches)
973+
self.assertFalse(restored_component.contribute_project_tm)
974+
self.assertEqual(
975+
restored_component.file_format_params, component.file_format_params
976+
)
977+
self.assertEqual(restored_component.screenshot_filemask, "screenshots/*.png")
978+
self.assertEqual(restored_component.key_filter, "^keep")
979+
853980
def test_backup_team_members_prefetches_limit_languages(self) -> None:
854981
team = Group.objects.create(name="Prefetch team", defining_project=self.project)
855982
first_user = type(self.user).objects.create_user(

0 commit comments

Comments
 (0)