Repository: translate/pootle

Issue:
Add CLI command to update translated strings in related projects
Sometimes projects have (almost the) same sources and a way is needed to update newer translations from one to the other.



Discussion:

Resolving patch:
diff --git a/pootle/apps/pootle_app/management/commands/project.py b/pootle/apps/pootle_app/management/commands/project.py
new file mode 100644
--- /dev/null
+++ b/pootle/apps/pootle_app/management/commands/project.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) Pootle contributors.
+#
+# This file is a part of the Pootle project. It is distributed under the GPL3
+# or later license. See the LICENSE file for a copy of the license and the
+# AUTHORS file for copyright and authorship information.
+
+import logging
+import os
+
+# This must be run before importing the rest of the Django libs.
+os.environ['DJANGO_SETTINGS_MODULE'] = 'pootle.settings'
+
+from pootle.core.management.subcommands import CommandWithSubcommands
+
+from .project_commands import UpdateCommand
+
+
+logger = logging.getLogger()
+
+
+class Command(CommandWithSubcommands):
+    help = "Pootle project (via TPTool) API."
+    subcommands = {
+        "update": UpdateCommand,
+    }
diff --git a/pootle/apps/pootle_app/management/commands/project_commands/__init__.py b/pootle/apps/pootle_app/management/commands/project_commands/__init__.py
new file mode 100644
--- /dev/null
+++ b/pootle/apps/pootle_app/management/commands/project_commands/__init__.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) Pootle contributors.
+#
+# This file is a part of the Pootle project. It is distributed under the GPL3
+# or later license. See the LICENSE file for a copy of the license and the
+# AUTHORS file for copyright and authorship information.
+
+import logging
+
+from django.core.management.base import BaseCommand, CommandError
+from django.utils.lru_cache import lru_cache
+
+from pootle_project.models import Project
+from pootle_translationproject.models import TranslationProject
+from pootle.core.delegate import tp_tool as tp_tool_getter
+
+
+def get_project(project_code):
+    try:
+        return Project.objects.get(code=project_code)
+    except Project.DoesNotExist as e:
+        raise CommandError(e)
+
+
+class TPToolProjectSubCommand(BaseCommand):
+    requires_system_checks = False
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            'source_project',
+            type=str,
+            help='Source Pootle project',
+        )
+        parser.add_argument(
+            '--target-project',
+            type=str,
+            help='Target Pootle project',
+            required=True,
+        )
+        parser.add_argument(
+            '--language',
+            action='append',
+            dest='languages',
+            help='Language to refresh',
+        )
+        super(TPToolProjectSubCommand, self).add_arguments(parser)
+
+    @lru_cache()
+    def get_tp_tool(self, project_code):
+        project = self.get_project(project_code)
+        return tp_tool_getter.get(Project)(project)
+
+    @lru_cache()
+    def get_project(self, project_code):
+        return get_project(project_code)
+
+
+class UpdateCommand(TPToolProjectSubCommand):
+    help = """Update project."""
+
+    def add_arguments(self, parser):
+        super(UpdateCommand, self).add_arguments(parser)
+        parser.add_argument(
+            "--translations",
+            action="store_true",
+            default=False,
+            dest="translations",
+            help="Flag if allow to add new and make obsolete stores and units."
+        )
+
+    def handle(self, *args, **options):
+        tp_tool = self.get_tp_tool(options['source_project'])
+        target_project = self.get_project(options['target_project'])
+        tp_query = tp_tool.tp_qs.all()
+
+        if options['languages']:
+            tp_query = tp_query.filter(language__code__in=options['languages'])
+
+        for source_tp in tp_query:
+            target_tps = tp_tool.get_tps(target_project)
+            try:
+                target_tp = target_tps.get(language__code=source_tp.language.code)
+                tp_tool.update_from_tp(
+                    source_tp,
+                    target_tp,
+                    allow_add_and_obsolete=not options['translations'],
+                )
+            except TranslationProject.DoesNotExist:
+                logging.warning(
+                    u"Translation project (/%s/%s) is missing.",
+                    source_tp.language.code, target_project.code
+                )
diff --git a/pootle/apps/pootle_translationproject/utils.py b/pootle/apps/pootle_translationproject/utils.py
--- a/pootle/apps/pootle_translationproject/utils.py
+++ b/pootle/apps/pootle_translationproject/utils.py
@@ -177,7 +177,8 @@ def set_parents(self, directory, parent, project=None, update_cache=True):
             self.set_parents(
                 subdir, subdir, project, update_cache=update_cache)
 
-    def update_children(self, source_dir, target_dir, update_cache=True):
+    def update_children(self, source_dir, target_dir, update_cache=True,
+                        allow_add_and_obsolete=True):
         """Update a target Directory and its children from a given
         source Directory
         """
@@ -191,9 +192,12 @@ def update_children(self, source_dir, target_dir, update_cache=True):
             try:
                 self.update_store(
                     store,
-                    target_dir.child_stores.get(name=store.name))
+                    target_dir.child_stores.get(name=store.name),
+                    allow_add_and_obsolete=allow_add_and_obsolete,
+                )
             except target_dir.child_stores.model.DoesNotExist:
-                self.clone_store(store, target_dir, update_cache=update_cache)
+                if allow_add_and_obsolete:
+                    self.clone_store(store, target_dir, update_cache=update_cache)
         for subdir in source_dir.child_dirs.live():
             subdir.parent = source_dir
             dirs.append(subdir.name)
@@ -201,22 +205,26 @@ def update_children(self, source_dir, target_dir, update_cache=True):
                 self.update_children(
                     subdir,
                     target_dir.child_dirs.get(name=subdir.name),
-                    update_cache=update_cache)
+                    update_cache=update_cache,
+                    allow_add_and_obsolete=allow_add_and_obsolete,
+                )
             except target_dir.child_dirs.model.DoesNotExist:
                 self.clone_directory(
                     subdir, target_dir, update_cache=update_cache)
 
-        for store in target_dir.child_stores.exclude(name__in=stores):
-            store.makeobsolete()
+        if allow_add_and_obsolete:
+            for store in target_dir.child_stores.exclude(name__in=stores):
+                store.makeobsolete()
 
-    def update_from_tp(self, source, target, update_cache=True):
+    def update_from_tp(self, source, target, update_cache=True,
+                       allow_add_and_obsolete=True):
         """Update one TP from another"""
         self.check_tp(source)
-        self.check_tp(target)
         self.update_children(
-            source.directory, target.directory, update_cache=update_cache)
+            source.directory, target.directory, update_cache=update_cache,
+            allow_add_and_obsolete=allow_add_and_obsolete)
 
-    def update_store(self, source, target):
+    def update_store(self, source, target, allow_add_and_obsolete=True):
         """Update a target Store from a given source Store"""
         source_revision = target.data.max_unit_revision + 1
         differ = StoreDiff(target, source, source_revision)
@@ -233,4 +241,4 @@ def update_store(self, source, target):
             system,
             SubmissionTypes.SYSTEM,
             SOURCE_WINS,
-            True)
+            allow_add_and_obsolete)


Test patch:
diff --git a/tests/commands/project.py b/tests/commands/project.py
new file mode 100644
--- /dev/null
+++ b/tests/commands/project.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) Pootle contributors.
+#
+# This file is a part of the Pootle project. It is distributed under the GPL3
+# or later license. See the LICENSE file for a copy of the license and the
+# AUTHORS file for copyright and authorship information.
+
+import pytest
+
+from django.core.management import CommandError, call_command
+
+from pootle.core.delegate import tp_tool
+from pootle_project.models import Project
+from pootle_store.models import Store
+
+
+@pytest.mark.cmd
+@pytest.mark.django_db
+def test_wrong_project_update():
+    with pytest.raises(CommandError):
+        call_command('project', 'update', 'wrong_project',
+                     '--target-project=foo')
+
+
+@pytest.mark.cmd
+@pytest.mark.django_db
+def test_project_update(capfd, language0, project0, project_foo):
+    from pytest_pootle.factories import StoreDBFactory
+
+    call_command('project', 'update', 'project0',
+                 '--target-project=foo', '--language=language0')
+    out, err = capfd.readouterr()
+
+    assert "Translation project (/language0/foo) is missing" in err
+    assert Store.objects.filter(
+        translation_project__project=project_foo).count() == 0
+
+    tp = project0.translationproject_set.get(language=language0)
+    tptool = tp_tool.get(Project)(project0)
+    cloned_tp = tptool.clone(tp, language0, project=project_foo)
+
+    store = tp.stores.first()
+    unit = store.units.first()
+    unit.target_f += ' CHANGED'
+    unit.save()
+
+    new_store = StoreDBFactory(translation_project=tp)
+
+    call_command('project', 'update', '--translations',
+                 'project0', '--target-project=foo')
+
+    cloned_store = cloned_tp.stores.get(
+        pootle_path=store.pootle_path.replace('project0', 'foo'))
+    cloned_unit = cloned_store.units.get(source_hash=unit.source_hash)
+    assert cloned_unit.target_f == unit.target_f
+
+    with pytest.raises(Store.DoesNotExist):
+        cloned_tp.stores.get(
+            pootle_path=new_store.pootle_path.replace('project0', 'foo'))
+
+    call_command('project', 'update', 'project0',
+                 '--target-project=foo')
+
+    new_cloned_store = cloned_tp.stores.get(
+        pootle_path=new_store.pootle_path.replace('project0', 'foo'))
+
+    assert new_cloned_store
diff --git a/tests/models/project.py b/tests/models/project.py
--- a/tests/models/project.py
+++ b/tests/models/project.py
@@ -312,7 +312,7 @@ def test_root_hide_permissions(po_directory, nobody, default, admin, hide,
 @pytest.mark.django_db
 def test_project_create_with_none_treestyle(english, templates, settings):
     project = Project.objects.create(
-        code="foo",
+        code="fs_foo",
         fullname="bar",
         source_language=english,
         treestyle='pootle_fs')