Repository: WeblateOrg/weblate

Issue:
Define state of the unit on create via API
### Describe the problem

It would be greate to have a way to define state of the unit on creation via an API.
If we will do it in 2 steps (1 — create unit, 2 — patch unit state), there may be an issue with data consistency. For example 1st request is done, but on second there will be network issue, or requester server has died unexpectedly.

`POST /api/translations/(string: project)/(string: component)/(string: language)/units/`
```json
{
    "key": "example_key",
    "value": ["Example translation"]
}
```

### Describe the solution you'd like

`POST /api/translations/(string: project)/(string: component)/(string: language)/units/`
```json
{
    "key": "example_key",
    "value": ["Example translation"],
    "state": 10 // Needs editing
}
```

### Describe alternatives you've considered

_No response_

### Screenshots

_No response_

### Additional context

_No response_


Discussion:
This issue seems to be a good fit for newbie contributors. You are welcome to contribute to Weblate! Don't hesitate to ask any questions you would have while implementing this.

You can learn about how to get started in our [contributors documentation](https://docs.weblate.org/en/latest/contributing/index.html).
Hey, i'd like to work on this!
You are welcome. In case you need any guidance don't hesitate to ask.
@scott-gorman any progress on it?
It would also be good to extend the API for `GET`ting a translation. This together with writing `POST` the state would be great for roundtrip needs.

Example:
We need to boldly work on translation keys. Therefore, old key with old value should go to new key with old value. Just doing this in the connected repository is working, but weblate is loosing the previous translation state. Therefore, we would be happy to use the API in away so that

1. `GET` (all) units with its state (-> do it with API)
2. copy over and create new keys with old values (-> could be done in connected repo (as-is))
3. `POST` (all) units' former states forward to the new keys (with old values) (-> do it with API)
4. removing old key value pairs (-> could be done in connected repo (as-is))

@scott-gorman could you consider this when workin on API extension?
@nijel with my example described in https://github.com/WeblateOrg/weblate/issues/7601#issuecomment-1316610725 above, this only addresses "meta data" as the state. But when doing such operations as mentioned, it would be good to act on further "meta data" as `suggestions` and `comments` , so that potentially API would allow to act on them as well.

Would this fit to this issue or shall I write a new one?
@rhofer  Please open separate issue for each of these.

Resolving patch:
diff --git a/weblate/api/serializers.py b/weblate/api/serializers.py
--- a/weblate/api/serializers.py
+++ b/weblate/api/serializers.py
@@ -28,6 +28,7 @@
 )
 from weblate.trans.util import check_upload_method_permissions, cleanup_repo_url
 from weblate.utils.site import get_site_url
+from weblate.utils.state import STATE_CHOICES, STATE_READONLY
 from weblate.utils.validators import validate_bitmap
 from weblate.utils.views import (
     create_component_from_doc,
@@ -1032,6 +1033,11 @@ def to_internal_value(self, data):
 
 
 class NewUnitSerializer(serializers.Serializer):
+    state = serializers.ChoiceField(
+        choices=[choice for choice in STATE_CHOICES if choice[0] != STATE_READONLY],
+        required=False,
+    )
+
     def as_kwargs(self, data=None):
         raise NotImplementedError()
 
@@ -1052,7 +1058,12 @@ class MonolingualUnitSerializer(NewUnitSerializer):
     def as_kwargs(self, data=None):
         if data is None:
             data = self.validated_data
-        return {"context": data["key"], "source": data["value"], "target": None}
+        return {
+            "context": data["key"],
+            "source": data["value"],
+            "target": None,
+            "state": data.get("state", None),
+        }
 
 
 class BilingualUnitSerializer(NewUnitSerializer):
@@ -1067,6 +1078,7 @@ def as_kwargs(self, data=None):
             "context": data.get("context", ""),
             "source": data["source"],
             "target": data["target"],
+            "state": data.get("state", None),
         }
 
 
diff --git a/weblate/trans/models/translation.py b/weblate/trans/models/translation.py
--- a/weblate/trans/models/translation.py
+++ b/weblate/trans/models/translation.py
@@ -34,13 +34,7 @@
 from weblate.trans.mixins import CacheKeyMixin, LoggerMixin, URLMixin
 from weblate.trans.models.change import Change
 from weblate.trans.models.suggestion import Suggestion
-from weblate.trans.models.unit import (
-    STATE_APPROVED,
-    STATE_EMPTY,
-    STATE_FUZZY,
-    STATE_TRANSLATED,
-    Unit,
-)
+from weblate.trans.models.unit import Unit
 from weblate.trans.models.variant import Variant
 from weblate.trans.signals import component_post_update, store_post_load, vcs_pre_commit
 from weblate.trans.util import join_plural, split_plural
@@ -48,6 +42,12 @@
 from weblate.utils.errors import report_error
 from weblate.utils.render import render_template
 from weblate.utils.site import get_site_url
+from weblate.utils.state import (
+    STATE_APPROVED,
+    STATE_EMPTY,
+    STATE_FUZZY,
+    STATE_TRANSLATED,
+)
 from weblate.utils.stats import GhostStats, TranslationStats
 
 
@@ -1341,6 +1341,7 @@ def add_unit(  # noqa: C901
         is_batch_update: bool = False,
         skip_existing: bool = False,
         sync_terminology: bool = True,
+        state: Optional[int] = None,
     ):
         if isinstance(source, list):
             source = join_plural(source)
@@ -1414,12 +1415,18 @@ def add_unit(  # noqa: C901
                 except Unit.DoesNotExist:
                     pass
             if unit is None:
+                if state is None:
+                    unit_state = (
+                        STATE_TRANSLATED if bool(current_target) else STATE_EMPTY
+                    )
+                else:
+                    unit_state = state
                 unit = Unit(
                     translation=translation,
                     context=context,
                     source=source,
                     target=current_target,
-                    state=STATE_TRANSLATED if bool(current_target) else STATE_EMPTY,
+                    state=unit_state,
                     source_unit=source_unit,
                     id_hash=id_hash,
                     position=translation.stats.all + 1,
@@ -1572,6 +1579,7 @@ def validate_new_unit_data(  # noqa: C901
         auto_context: bool = False,
         extra_flags: Optional[str] = None,
         explanation: str = "",
+        state: Optional[int] = None,
     ):
         extra = {}
         if isinstance(source, str):
@@ -1581,6 +1589,17 @@ def validate_new_unit_data(  # noqa: C901
                 raise ValidationError(
                     _("String contains control character: %s") % repr(text)
                 )
+        if state is not None:
+            if state == STATE_EMPTY and any(source):
+                raise ValidationError(
+                    _("Empty state is supported for blank strings only.")
+                )
+            if not any(source) and state != STATE_EMPTY:
+                raise ValidationError(_("Blank strings reqire an empty state."))
+            if state == STATE_APPROVED and not self.enable_review:
+                raise ValidationError(
+                    _("Appoved state is not available as reviews are not enabled.")
+                )
         if context:
             self.component.file_format_cls.validate_context(context)
         if not self.component.has_template():


Test patch:
diff --git a/weblate/api/tests.py b/weblate/api/tests.py
--- a/weblate/api/tests.py
+++ b/weblate/api/tests.py
@@ -2781,6 +2781,91 @@ def test_add_bilingual(self):
             request={"source": "Source", "target": "Target", "context": "Another"},
             code=200,
         )
+        self.do_request(
+            "api:translation-units",
+            {
+                "language__code": "cs",
+                "component__slug": "test",
+                "component__project__slug": "test",
+            },
+            method="post",
+            superuser=True,
+            request={
+                "source": "Source",
+                "target": "Target",
+                "context": "Wrong",
+                "state": "0",
+            },
+            code=400,
+        )
+        self.do_request(
+            "api:translation-units",
+            {
+                "language__code": "cs",
+                "component__slug": "test",
+                "component__project__slug": "test",
+            },
+            method="post",
+            superuser=True,
+            request={
+                "source": "Source",
+                "target": "Target",
+                "context": "Wrong",
+                "state": "30",
+            },
+            code=400,
+        )
+        self.do_request(
+            "api:translation-units",
+            {
+                "language__code": "cs",
+                "component__slug": "test",
+                "component__project__slug": "test",
+            },
+            method="post",
+            superuser=True,
+            request={
+                "source": "Source",
+                "target": "Target",
+                "context": "Wrong",
+                "state": "100",
+            },
+            code=400,
+        )
+        self.do_request(
+            "api:translation-units",
+            {
+                "language__code": "cs",
+                "component__slug": "test",
+                "component__project__slug": "test",
+            },
+            method="post",
+            superuser=True,
+            request={
+                "source": "Source",
+                "target": "",
+                "context": "Wrong",
+                "state": "0",
+            },
+            code=400,
+        )
+        self.do_request(
+            "api:translation-units",
+            {
+                "language__code": "cs",
+                "component__slug": "test",
+                "component__project__slug": "test",
+            },
+            method="post",
+            superuser=True,
+            request={
+                "source": "Source",
+                "target": "Target",
+                "context": "Fuzzy",
+                "state": "10",
+            },
+            code=200,
+        )
 
     def test_delete(self):
         start_count = Translation.objects.count()