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()