Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

10896 Duplicate component shortcut #12320

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Not yet released.

**Improvements**

* A shortcut to duplicate a component is now available directly in the menu (:guilabel:`Manage` → :guilabel:`Duplicate Component`)

**Bug fixes**

* Update outdated plural definitions during the database migration.
Expand Down
1 change: 1 addition & 0 deletions weblate/templates/component.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
<li><a href="{% url 'manage-access' project=object.project.slug %}">{% trans "Users" %}</a></li>
{% endif %}
{% if user_can_edit_component %}
<li><a href="{% url 'create-component' %}?component={{ object.id }}#existing">{% trans "Duplicate Component" %}</li>
<li><a href="{% url 'guide' path=object.get_url_path %}">{% trans "Community localization checklist" %}</a></li>
<li><a href="{% url 'addons' path=object.get_url_path %}">{% trans "Add-ons" %}</a></li>
{% endif %}
Expand Down
6 changes: 6 additions & 0 deletions weblate/trans/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
104 changes: 102 additions & 2 deletions weblate/trans/tests/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 4 additions & 8 deletions weblate/trans/tests/test_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -397,15 +396,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()}
8 changes: 7 additions & 1 deletion weblate/trans/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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":
Expand Down
52 changes: 51 additions & 1 deletion weblate/trans/views/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -437,6 +464,7 @@ class CreateComponentSelection(CreateComponent):

components: ComponentQuerySet
origin: None | str = None
duplicate_existing_component = None

@cached_property
def branch_data(self):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
pk=self.duplicate_existing_component
)
form.fields["component"].queryset = self.components
return form

Expand All @@ -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()
Expand Down
Loading