Repository: WeblateOrg/weblate

Issue:
Cannot create a component reusing an existing component's name (REST API)
### Describe the issue

We can create components with the same names in different categories using Weblate's UI, but it is not possible using the REST API.

For instance, (with project/category/component):
project/master/installer
project/23.11/installer

The response is: `{"name":["Component or category with this name already exists."]}`

### I already tried

- [X] I've read and searched [the documentation](https://docs.weblate.org/).
- [X] I've searched for similar issues in this repository.

### Steps to reproduce the behavior

Use curl to POST some data to https://translate.koha-community.org/api/projects/koha/components/ with at least
{ "name": "installer", "slug": "installer", "category": "https://translate.koha-community.org/api/categories/2/" }

It will be rejected if installer already exists in categories/1

### Expected behavior

_No response_

### Screenshots

_No response_

### Exception traceback

_No response_

### How do you run Weblate?

Docker container

### Weblate versions

_No response_

### Weblate deploy checks

_No response_

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/weblate/api/serializers.py b/weblate/api/serializers.py
--- a/weblate/api/serializers.py
+++ b/weblate/api/serializers.py
@@ -643,57 +643,46 @@ def to_internal_value(self, data):
         return result
 
     def validate(self, attrs):
-        # Validate name/slug uniqueness, this has to be done prior docfile/zipfile
-        # extracting
-        for field in ("name", "slug"):
-            # Skip optional fields on PATCH
-            if field not in attrs:
-                continue
-            # Skip non changed fields
-            if self.instance and getattr(self.instance, field) == attrs[field]:
-                continue
-            # Look for existing components
-            project = attrs["project"]
-            field_filter = {field: attrs[field]}
-            if (
-                project.component_set.filter(**field_filter).exists()
-                or project.category_set.filter(**field_filter).exists()
-            ):
-                raise serializers.ValidationError(
-                    {field: f"Component or category with this {field} already exists."}
-                )
-
-        # Handle uploaded files
-        if self.instance:
-            for field in ("docfile", "zipfile"):
-                if field in attrs:
-                    raise serializers.ValidationError(
-                        {field: "This field is for creation only, use /file/ instead."}
-                    )
-        if "docfile" in attrs:
-            fake = create_component_from_doc(attrs)
-            attrs["template"] = fake.template
-            attrs["new_base"] = fake.template
-            attrs["filemask"] = fake.filemask
-            attrs.pop("docfile")
-        if "zipfile" in attrs:
-            try:
-                create_component_from_zip(attrs)
-            except BadZipfile:
-                raise serializers.ValidationError(
-                    {"zipfile": "Could not parse uploaded ZIP file."}
-                )
-            attrs.pop("zipfile")
-        # Handle non-component arg
+        # Handle non-component args
         disable_autoshare = attrs.pop("disable_autoshare", False)
+        docfile = attrs.pop("docfile", None)
+        zipfile = attrs.pop("zipfile", None)
 
-        # Call model validation here, DRF does not do that
+        # Restrict create fields on patching
+        if self.instance and (docfile is not None or zipfile is not None):
+            field = "docfile" if docfile is not None else "zipfile"
+            raise serializers.ValidationError(
+                {field: "This field is for creation only, use /file/ instead."}
+            )
+
+        # Build new or patched Component instance with changed attributes
         if self.instance:
             instance = copy(self.instance)
             for key, value in attrs.items():
                 setattr(instance, key, value)
         else:
             instance = Component(**attrs)
+
+        if docfile is not None or zipfile is not None:
+            # Validate name/slug uniqueness, this has to be done prior docfile/zipfile
+            # extracting
+            instance.clean_unique_together()
+
+            # Handle uploaded files
+            if docfile is not None:
+                fake = create_component_from_doc(attrs, docfile)
+                instance.template = attrs["template"] = fake.template
+                instance.new_base = attrs["new_base"] = fake.template
+                instance.filemask = attrs["filemask"] = fake.filemask
+            if zipfile is not None:
+                try:
+                    create_component_from_zip(attrs, zipfile)
+                except BadZipfile:
+                    raise serializers.ValidationError(
+                        {"zipfile": "Could not parse uploaded ZIP file."}
+                    )
+
+        # Call model validation here, DRF does not do that
         instance.clean()
 
         if not self.instance and not disable_autoshare:
diff --git a/weblate/trans/views/create.py b/weblate/trans/views/create.py
--- a/weblate/trans/views/create.py
+++ b/weblate/trans/views/create.py
@@ -407,7 +407,9 @@ def form_valid(self, form):
         if self.stage != "init":
             return super().form_valid(form)
 
-        fake = create_component_from_doc(form.cleaned_data)
+        fake = create_component_from_doc(
+            form.cleaned_data, form.cleaned_data.pop("docfile")
+        )
 
         # Move to discover phase
         self.stage = "discover"
@@ -417,7 +419,7 @@ def form_valid(self, form):
         self.initial["branch"] = "main"
         self.initial["template"] = fake.template
         self.initial["filemask"] = fake.filemask
-        self.initial.pop("docfile")
+
         self.request.method = "GET"
         return self.get(self, self.request)
 
diff --git a/weblate/utils/views.py b/weblate/utils/views.py
--- a/weblate/utils/views.py
+++ b/weblate/utils/views.py
@@ -338,13 +338,16 @@ def parse_path_units(request, path: list[str], types: tuple[Any]):
     return obj, unit_set, context
 
 
-def guess_filemask_from_doc(data):
+def guess_filemask_from_doc(data, docfile=None):
     if "filemask" in data:
         return
 
+    if docfile is None:
+        docfile = data["docfile"]
+
     ext = ""
-    if "docfile" in data and hasattr(data["docfile"], "name"):
-        ext = os.path.splitext(os.path.basename(data["docfile"].name))[1]
+    if hasattr(docfile, "name"):
+        ext = os.path.splitext(os.path.basename(docfile.name))[1]
 
     if not ext and "file_format" in data and data["file_format"] in FILE_FORMATS:
         ext = FILE_FORMATS[data["file_format"]].extension()
@@ -352,10 +355,10 @@ def guess_filemask_from_doc(data):
     data["filemask"] = "{}/{}{}".format(data.get("slug", "translations"), "*", ext)
 
 
-def create_component_from_doc(data):
+def create_component_from_doc(data, docfile):
     # Calculate filename
-    uploaded = data["docfile"]
-    guess_filemask_from_doc(data)
+    uploaded = docfile or data["docfile"]
+    guess_filemask_from_doc(data, uploaded)
     filemask = data["filemask"]
     filename = filemask.replace(
         "*",
@@ -377,7 +380,7 @@ def create_component_from_doc(data):
     return fake
 
 
-def create_component_from_zip(data):
+def create_component_from_zip(data, zipfile=None):
     # Create fake component (needed to calculate path)
     fake = Component(
         project=data["project"],
@@ -387,7 +390,7 @@ def create_component_from_zip(data):
     )
 
     # Create repository
-    LocalRepository.from_zip(fake.full_path, data["zipfile"])
+    LocalRepository.from_zip(fake.full_path, zipfile or data["zipfile"])
     return fake
 
 


Test patch: