Repository: WeblateOrg/weblate

Issue:
Update unit using API
**Is your feature request related to a problem? Please describe.**
I am migrating current localization to Weblate - I want to transfer also the metadata to strings plus I need to do some adjustments as the weblate set-up is different.

**Describe the solution you'd like**
via API, I need to be able to:
- tick off "needs editing" at a string
- set flag "read-only" at the string in source list
- set a commnent in "explanation"

**Describe alternatives you've considered**

**Additional context**



Discussion:
Should be done after https://github.com/WeblateOrg/weblate/issues/4020 which will probably change way we link to the source strings.

Resolving patch:
diff --git a/weblate/api/serializers.py b/weblate/api/serializers.py
--- a/weblate/api/serializers.py
+++ b/weblate/api/serializers.py
@@ -716,6 +716,11 @@ def to_representation(self, instance):
         return result
 
 
+class PluralField(serializers.ListField):
+    def get_attribute(self, instance):
+        return getattr(instance, f"get_{self.field_name}_plurals")()
+
+
 class UnitSerializer(serializers.ModelSerializer):
     web_url = AbsoluteURLField(source="get_absolute_url", read_only=True)
     translation = MultiFieldHyperlinkedIdentityField(
@@ -730,6 +735,8 @@ class UnitSerializer(serializers.ModelSerializer):
     source_unit = serializers.HyperlinkedRelatedField(
         read_only=True, view_name="api:unit-detail"
     )
+    source = PluralField()
+    target = PluralField()
 
     class Meta:
         model = Unit
@@ -744,6 +751,7 @@ class Meta:
             "context",
             "note",
             "flags",
+            "state",
             "fuzzy",
             "translated",
             "approved",
@@ -763,6 +771,45 @@ class Meta:
         extra_kwargs = {"url": {"view_name": "api:unit-detail"}}
 
 
+class ReadonlySourceUnitWriteSerializer(serializers.ModelSerializer):
+    """Serializer for updating readonly source unit."""
+
+    class Meta:
+        model = Unit
+        fields = (
+            "explanation",
+            "extra_flags",
+        )
+
+
+class SourceUnitWriteSerializer(serializers.ModelSerializer):
+    """Serializer for updating source unit."""
+
+    target = PluralField()
+
+    class Meta:
+        model = Unit
+        fields = (
+            "target",
+            "state",
+            "explanation",
+            "extra_flags",
+        )
+
+
+class UnitWriteSerializer(serializers.ModelSerializer):
+    """Serializer for updating target unit."""
+
+    target = PluralField()
+
+    class Meta:
+        model = Unit
+        fields = (
+            "target",
+            "state",
+        )
+
+
 class ScreenshotSerializer(RemovableSerializer):
     component = MultiFieldHyperlinkedIdentityField(
         view_name="api:component-detail",
diff --git a/weblate/api/views.py b/weblate/api/views.py
--- a/weblate/api/views.py
+++ b/weblate/api/views.py
@@ -57,13 +57,16 @@
     MonolingualUnitSerializer,
     NotificationSerializer,
     ProjectSerializer,
+    ReadonlySourceUnitWriteSerializer,
     RepoRequestSerializer,
     RoleSerializer,
     ScreenshotFileSerializer,
     ScreenshotSerializer,
+    SourceUnitWriteSerializer,
     StatisticsSerializer,
     TranslationSerializer,
     UnitSerializer,
+    UnitWriteSerializer,
     UploadRequestSerializer,
     UserStatisticsSerializer,
 )
@@ -87,6 +90,12 @@
 from weblate.utils.celery import get_queue_stats
 from weblate.utils.docs import get_doc_url
 from weblate.utils.errors import report_error
+from weblate.utils.state import (
+    STATE_APPROVED,
+    STATE_EMPTY,
+    STATE_FUZZY,
+    STATE_TRANSLATED,
+)
 from weblate.utils.stats import GlobalStats
 from weblate.utils.views import download_translation_file, zip_download
 from weblate.wladmin.models import ConfigurationError
@@ -1063,15 +1072,85 @@ def statistics(self, request, **kwargs):
         return Response(serializer.data)
 
 
-class UnitViewSet(viewsets.ReadOnlyModelViewSet):
+class UnitViewSet(viewsets.ReadOnlyModelViewSet, UpdateModelMixin):
     """Units API."""
 
     queryset = Unit.objects.none()
-    serializer_class = UnitSerializer
+
+    def get_serializer(self, instance, *args, **kwargs):
+        # Get correct serializer based on action and instance
+        if self.action in ("list", "retrieve"):
+            serializer_class = UnitSerializer
+        elif isinstance(instance, Unit) and instance.translation.is_source:
+            if instance.readonly:
+                serializer_class = ReadonlySourceUnitWriteSerializer
+            else:
+                serializer_class = SourceUnitWriteSerializer
+        else:
+            serializer_class = UnitWriteSerializer
+        kwargs["context"] = self.get_serializer_context()
+        return serializer_class(instance, *args, **kwargs)
 
     def get_queryset(self):
         return Unit.objects.filter_access(self.request.user).order_by("id")
 
+    def perform_update(self, serializer):
+        data = serializer.validated_data
+        do_translate = "target" in data or "state" in data
+        do_source = "extra_flags" in data or "explanation" in data
+        unit = serializer.instance
+        translation = unit.translation
+        user = self.request.user
+
+        new_target = data.get("target", [])
+        new_state = data.get("state", None)
+
+        # Sanity and permission checks
+        if do_source and not user.has_perm("source.edit", translation):
+            raise PermissionDenied()
+
+        if do_translate:
+            if not new_target or new_state is None:
+                raise ParseError(
+                    "Please provide both state and target for a partial update"
+                )
+
+            if new_state not in (
+                STATE_APPROVED,
+                STATE_TRANSLATED,
+                STATE_FUZZY,
+                STATE_EMPTY,
+            ):
+                raise ParseError("Invalid state")
+
+            if new_state == STATE_EMPTY and any(new_target):
+                raise ParseError("Can not use empty state with non empty target")
+
+            if new_state != STATE_EMPTY and not any(new_target):
+                raise ParseError("Can not use non empty state with empty target")
+
+            if not user.has_perm("unit.edit", unit):
+                raise PermissionDenied()
+
+            if new_state == STATE_APPROVED and not user.has_perm(
+                "unit.review", translation
+            ):
+                raise PermissionDenied()
+
+        # Update attributes
+        if do_source:
+            fields = ["extra_flags", "explanation"]
+            for name in fields:
+                try:
+                    setattr(unit, name, data[name])
+                except KeyError:
+                    continue
+            unit.save(update_fields=fields)
+
+        # Handle translate
+        if do_translate:
+            unit.translate(user, new_target, new_state)
+
 
 class ScreenshotViewSet(DownloadViewSet, viewsets.ModelViewSet):
     """Screenshots API."""


Test patch:
diff --git a/weblate/api/tests.py b/weblate/api/tests.py
--- a/weblate/api/tests.py
+++ b/weblate/api/tests.py
@@ -1887,9 +1887,164 @@ def test_list_units(self):
         self.assertEqual(response.data["count"], 16)
 
     def test_get_unit(self):
-        unit = Unit.objects.filter(translation__language_code="cs")[0]
+        unit = Unit.objects.get(
+            translation__language_code="cs", source="Hello, world!\n"
+        )
+        response = self.client.get(reverse("api:unit-detail", kwargs={"pk": unit.pk}))
+        self.assertIn("translation", response.data)
+        self.assertEqual(response.data["source"], ["Hello, world!\n"])
+
+    def test_get_plural_unit(self):
+        unit = Unit.objects.get(
+            translation__language_code="cs", source__startswith="Orangutan has "
+        )
         response = self.client.get(reverse("api:unit-detail", kwargs={"pk": unit.pk}))
         self.assertIn("translation", response.data)
+        self.assertEqual(
+            response.data["source"],
+            ["Orangutan has %d banana.\n", "Orangutan has %d bananas.\n"],
+        )
+
+    def test_translate_unit(self):
+        unit = Unit.objects.get(
+            translation__language_code="cs", source="Hello, world!\n"
+        )
+        # Changing state only
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=400,
+            request={"state": "20"},
+        )
+        # Changing target only
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=400,
+            request={"target": "Test translation"},
+        )
+        # Performing update
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=200,
+            request={"state": "20", "target": "Test translation"},
+        )
+        # Invalid state changes
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=400,
+            request={"state": "100", "target": "Test read only translation"},
+        )
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=400,
+            request={"state": "0", "target": "Test read only translation"},
+        )
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=400,
+            request={"state": "20", "target": ""},
+        )
+        unit = Unit.objects.get(pk=unit.pk)
+        # The auto fixer adds the trailing newline
+        self.assertEqual(unit.target, "Test translation\n")
+
+    def test_unit_review(self):
+        self.component.project.translation_review = True
+        self.component.project.save()
+        unit = Unit.objects.get(
+            translation__language_code="cs", source="Hello, world!\n"
+        )
+        # Changing to approved is not allowed without perms
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=403,
+            request={"state": "30", "target": "Test translation"},
+        )
+        self.assertFalse(Unit.objects.get(pk=unit.pk).approved)
+
+        # Changing state to approved
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=200,
+            superuser=True,
+            request={"state": "30", "target": "Test translation"},
+        )
+        self.assertTrue(Unit.objects.get(pk=unit.pk).approved)
+
+        # Changing approved unit is not allowed
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=403,
+            request={"state": "20", "target": "Test translation"},
+        )
+        self.assertTrue(Unit.objects.get(pk=unit.pk).approved)
+
+    def test_translate_source_unit(self):
+        unit = Unit.objects.get(
+            translation__language_code="en", source="Hello, world!\n"
+        )
+        # The params are silently ignored here
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=200,
+            request={"state": "20", "target": "Test translation"},
+        )
+        unit = Unit.objects.get(pk=unit.pk)
+        self.assertEqual(unit.target, "Hello, world!\n")
+        # Actual update
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=200,
+            superuser=True,
+            request={"explanation": "This is good explanation"},
+        )
+        # No permissions
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=403,
+            request={"explanation": "This is wrong explanation"},
+        )
+        unit = Unit.objects.get(pk=unit.pk)
+        self.assertEqual(unit.explanation, "This is good explanation")
+
+    def test_translate_plural_unit(self):
+        unit = Unit.objects.get(
+            translation__language_code="cs", source__startswith="Orangutan has "
+        )
+        self.do_request(
+            "api:unit-detail",
+            kwargs={"pk": unit.pk},
+            method="patch",
+            code=200,
+            format="json",
+            request={"state": 20, "target": ["singular", "many", "other"]},
+        )
+        unit = Unit.objects.get(pk=unit.pk)
+        # The auto fixer adds the trailing newline
+        self.assertEqual(unit.get_target_plurals(), ["singular\n", "many\n", "other\n"])
 
 
 class ScreenshotAPITest(APIBaseTest):