diff --git a/docs/changes.rst b/docs/changes.rst index ee9158b2097a..cf34b8cd0dae 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,6 +7,7 @@ Not yet released. **Improvements** +* A shortcut to duplicate a component is now available directly in the menu (:guilabel:`Manage` → :guilabel:`Duplicate Component`) * Included username when generating :ref:`credits`. **Bug fixes** diff --git a/weblate/templates/component.html b/weblate/templates/component.html index 016210b45c77..5d5490cf8e72 100644 --- a/weblate/templates/component.html +++ b/weblate/templates/component.html @@ -98,6 +98,7 @@
  • {% trans "Users" %}
  • {% endif %} {% if user_can_edit_component %} +
  • {% trans "Duplicate Component" %}
  • {% trans "Community localization checklist" %}
  • {% trans "Add-ons" %}
  • {% endif %} diff --git a/weblate/trans/forms.py b/weblate/trans/forms.py index e055e687cb71..c01ec8d3c5da 100644 --- a/weblate/trans/forms.py +++ b/weblate/trans/forms.py @@ -1664,6 +1664,12 @@ def clean(self) -> None: class ComponentCreateForm(SettingsBaseForm, ComponentDocsMixin, ComponentAntispamMixin): """Component creation form.""" + source_component = forms.ModelChoiceField( + queryset=Component.objects.none(), + required=False, + widget=forms.HiddenInput(), + ) + class Meta: model = Component fields = [ diff --git a/weblate/trans/tests/test_create.py b/weblate/trans/tests/test_create.py index b63f7fb71053..f2ba0274b551 100644 --- a/weblate/trans/tests/test_create.py +++ b/weblate/trans/tests/test_create.py @@ -4,6 +4,8 @@ """Test for creating projects and models.""" +import urllib.parse + from django.core.files.uploadedfile import SimpleUploadedFile from django.test.utils import modify_settings, override_settings from django.urls import reverse @@ -180,22 +182,120 @@ def test_create_component_wizard(self) -> None: @modify_settings(INSTALLED_APPS={"remove": "weblate.billing"}) def test_create_component_existing(self) -> None: + from weblate.trans.models import Component + # Make superuser self.user.is_superuser = True self.user.save() + self.component.agreement = "test agreement" + self.component.merge_style = "merge" + self.component.commit_message = "test commit_message" + self.component.add_message = "test add_message" + self.component.delete_message = "test delete_message" + self.component.merge_message = "test merge_message" + self.component.addon_message = "test addon_message" + self.component.pull_message = "test pull_message" + self.component.save() + + response = self.client.get( + reverse("create-component") + f"?component={self.component.pk}#existing", + follow=True, + ) + # init step + self.assertContains(response, "Create component") + with override_settings(CREATE_GLOSSARIES=self.CREATE_GLOSSARIES): response = self.client.post( reverse("create-component"), { "origin": "existing", - "name": "Create Component", - "slug": "create-component", + "name": "Create Component From Existing", + "slug": "create-component-from-existing", "component": self.component.pk, + "is_glossary": self.component.is_glossary, }, follow=True, ) + self.assertContains(response, self.component.get_repo_link_url()) + parsed_query = urllib.parse.parse_qs(response.request["QUERY_STRING"]) + expected_query_strings = [ + "vcs", + "source_language", + "license", + ] + for field in expected_query_strings: + if component_value := getattr(self.component, field): + if field == "source_language": + component_value = str(component_value.id) + self.assertEqual(parsed_query[field][0], component_value) + + self.assertEqual(parsed_query["source_component"][0], str(self.component.pk)) + + # discovery step + self.assertContains(response, "Choose translation files to import") + + with override_settings(CREATE_GLOSSARIES=self.CREATE_GLOSSARIES): + response = self.client.post( + reverse("create-component-vcs") + + f"?source_component={self.component.pk}#existing,", + { + "name": "Create Component From Existing", + "slug": "create-component-from-existing", + "is_glossary": self.component.is_glossary, + "project": self.component.project_id, + "vcs": self.component.vcs, + "repo": self.component.repo, + "discovery": 28, # deep/*/locales/*/LC_MESSAGES/messages.po + "source_language": self.component.source_language_id, + }, + follow=True, + ) + self.assertContains( + response, + "You will be able to edit more options in the component settings after creating it.", + ) + + with override_settings(CREATE_GLOSSARIES=self.CREATE_GLOSSARIES): + response = self.client.post( + reverse("create-component-vcs") + + f"?source_component={self.component.pk}#existing,", + { + "name": "Create Component From Existing", + "slug": "create-component-from-existing", + "is_glossary": self.component.is_glossary, + "project": self.component.project_id, + "vcs": self.component.vcs, + "repo": self.component.repo, + "source_language": self.component.source_language_id, + "file_format": "po", + "filemask": "deep/*/locales/*/LC_MESSAGES/messages.po", + "new_lang": "add", + "new_base": "deep/cs/locales/cs/LC_MESSAGES/messages.po", + "language_regex": "^[^.]+$", + "source_component": self.component.pk, + }, + follow=True, + ) + self.assertContains(response, "Community localization checklist") + self.assertContains(response, "Test/Create Component From Existing @ Weblate") + + new_component = Component.objects.get(name="Create Component From Existing") + cloned_fields = [ + "agreement", + "merge_style", + "commit_message", + "add_message", + "delete_message", + "merge_message", + "addon_message", + "pull_message", + ] + for field in cloned_fields: + self.assertEqual( + getattr(new_component, field), getattr(self.component, field) + ) @modify_settings(INSTALLED_APPS={"remove": "weblate.billing"}) def test_create_component_branch_fail(self) -> None: diff --git a/weblate/trans/tests/test_reports.py b/weblate/trans/tests/test_reports.py index a48eb6c6983f..5b3ac0f05441 100644 --- a/weblate/trans/tests/test_reports.py +++ b/weblate/trans/tests/test_reports.py @@ -7,7 +7,6 @@ from django.urls import reverse from django.utils import timezone -from weblate.trans.models.category import Category from weblate.trans.tests.test_views import ViewTestCase from weblate.trans.views.reports import generate_counts, generate_credits @@ -399,15 +398,12 @@ def get_kwargs(self): class ReportsCategoryTest(ReportsComponentTest): def setUp(self) -> None: super().setUp() - self.category = self.create_category() + self.setup_category() - def create_category(self) -> None: - category = Category.objects.create( - name="test category", slug="test-category", project=self.project - ) - self.component.category = category + def setup_category(self) -> None: + self.component.category = self.create_category(project=self.project) self.component.save() - return category + self.category = self.component.category def get_kwargs(self) -> dict[str, tuple]: return {"path": self.category.get_url_path()} diff --git a/weblate/trans/tests/utils.py b/weblate/trans/tests/utils.py index 3d2456fbae4b..5f6fe62264f1 100644 --- a/weblate/trans/tests/utils.py +++ b/weblate/trans/tests/utils.py @@ -23,7 +23,7 @@ from weblate.auth.models import User from weblate.configuration.models import Setting, SettingCategory from weblate.formats.models import FILE_FORMATS -from weblate.trans.models import Component, Project +from weblate.trans.models import Category, Component, Project from weblate.utils.files import remove_tree from weblate.vcs.models import VCS_REGISTRY @@ -166,6 +166,12 @@ def create_project(self, **kwargs): self.addCleanup(remove_tree, project.full_path, True) return project + def create_category(self, project, **kwargs): + """Create test category.""" + return Category.objects.create( + name="Test category", slug="test-category", project=project, **kwargs + ) + def format_local_path(self, path): """Format path for local access to the repository.""" if sys.platform != "win32": diff --git a/weblate/trans/views/create.py b/weblate/trans/views/create.py index 9510a624baf3..c5304f347173 100644 --- a/weblate/trans/views/create.py +++ b/weblate/trans/views/create.py @@ -289,6 +289,22 @@ def form_valid(self, form): form.instance.manage_units = ( bool(form.instance.template) or form.instance.file_format == "tbx" ) + if self.duplicate_existing_component and ( + source_component := form.cleaned_data["source_component"] + ): + fields_to_duplicate = [ + "agreement", + "merge_style", + "commit_message", + "add_message", + "delete_message", + "merge_message", + "addon_message", + "pull_message", + ] + for field in fields_to_duplicate: + setattr(form.instance, field, getattr(source_component, field)) + result = super().form_valid(form) self.object.post_create(self.request.user) return result @@ -325,6 +341,12 @@ def get_form(self, form_class=None, empty=False): if self.selected_category: category_field.initial = self.selected_category self.empty_form = False + if "source_component" in form.fields and self.duplicate_existing_component: + self.components = Component.objects.filter( + pk=self.duplicate_existing_component + ) + form.fields["source_component"].queryset = self.components + form.initial["source_component"] = self.duplicate_existing_component return form def get_context_data(self, **kwargs): @@ -361,6 +383,11 @@ def fetch_params(self, request: AuthenticatedHttpRequest) -> None: if field in request.GET: self.initial[field] = request.GET[field] + try: + self.duplicate_existing_component = int(request.GET.get("source_component")) + except (ValueError, TypeError): + self.duplicate_existing_component = None + def has_all_fields(self): return self.stage == "init" and all( field in self.request.GET for field in self.basic_fields @@ -437,6 +464,7 @@ class CreateComponentSelection(CreateComponent): components: ComponentQuerySet origin: None | str = None + duplicate_existing_component = None @cached_property def branch_data(self): @@ -468,6 +496,20 @@ def fetch_params(self, request: AuthenticatedHttpRequest) -> None: self.components = self.components.filter(project__pk=self.selected_project) self.origin = request.POST.get("origin") + try: + self.duplicate_existing_component = int(request.GET.get("component")) + except (ValueError, TypeError): + self.duplicate_existing_component = None + self.initial = {} + if self.duplicate_existing_component: + source_component = Component.objects.get( + pk=self.duplicate_existing_component + ) + self.initial |= { + "component": source_component, + "is_glossary": source_component.is_glossary, + } + def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) kwargs["components"] = self.components @@ -498,6 +540,10 @@ def get_form(self, form_class=None, empty=False): ).order_project() form.branch_data = self.branch_data elif isinstance(form, ComponentSelectForm): + if self.duplicate_existing_component: + self.components |= Component.objects.filter_access( + self.request.user + ).filter(pk=self.duplicate_existing_component) form.fields["component"].queryset = self.components return form @@ -523,12 +569,16 @@ def form_valid(self, form): component = form.cleaned_data["component"] if self.origin == "existing": return self.redirect_create( - repo=component.get_repo_link_url(), + repo=component.repo or component.get_repo_link_url(), project=component.project.pk, + category=component.category.pk if component.category else "", name=form.cleaned_data["name"], slug=form.cleaned_data["slug"], + is_glossary=form.cleaned_data["is_glossary"], vcs=component.vcs, source_language=component.source_language.pk, + license=component.license, + source_component=component.pk, ) if self.origin == "branch": form.instance.save()