Repository: WeblateOrg/weblate

Issue:
[RestAPI] How to add translation to already created key?
### Describe the issue

In documentation I find `/api/translations/(string:project)/(string:component)/(string:language)/units/` endpoit that create a new unit. For example with `ru` language code. Next I try to create translation to this key by using same endpoint but another language code `kk`. But I got `403 Forbidden` message: 
```json
{
    "detail": "Can not add unit"
}
```

Question: **How can I add transation to created unit via RestAPI.**

### 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

1. Add translation to key for language via `/api/translations/(string:project)/(string:component)/(string:language)/units/` 
2. ...

### Expected behavior

To create translation for existing unit

### Screenshots

_No response_

### Exception traceback

_No response_

### How do you run Weblate?

PyPI module

### Weblate versions

_No response_

### Weblate deploy checks

_No response_

### Additional context

_No response_


Discussion:
This issue looks more like a support question than an issue. We strive to answer these reasonably fast, but [purchasing the support subscription](https://weblate.org/support/) is not only more responsible and faster for your business but also makes Weblate stronger.

In case your question is already answered, [making a donation](https://weblate.org/donate/) is the right way to say thank you!
I had the same problem today
```
{
  "detail": "Can not add unit"
}
Status: 403 Forbidden
```
I'm trying to add new unit translation via rest api but I receive status 403 and info can not add unit.

I do POST /api/translations/(string: project)/(string: component)/(string: language)/units/ with my token. and body:

{ "key": "somekey", "value": ["some value"] }

I can add translation unit via admin panel but when i try do this programatically i get response as described.
Could someone help me to resolve this issue ?

Resolving patch:
diff --git a/weblate/api/views.py b/weblate/api/views.py
--- a/weblate/api/views.py
+++ b/weblate/api/views.py
@@ -1108,8 +1108,9 @@ def units(self, request, **kwargs):
             serializer_class = BilingualUnitSerializer
 
         if request.method == "POST":
-            if not request.user.has_perm("unit.add", obj):
-                self.permission_denied(request, "Can not add unit")
+            can_add = request.user.has_perm("unit.add", obj)
+            if not can_add:
+                self.permission_denied(request, can_add.reason)
             serializer = serializer_class(
                 data=request.data, context={"translation": obj}
             )
@@ -1290,8 +1291,9 @@ def perform_update(self, serializer):
                     {"state": "Can not use non empty state with empty target"}
                 )
 
-            if not user.has_perm("unit.edit", unit):
-                raise PermissionDenied
+            can_edit = user.has_perm("unit.edit", unit)
+            if not can_edit:
+                self.permission_denied(request, can_edit.reason)
 
             if new_state == STATE_APPROVED and not user.has_perm(
                 "unit.review", translation
@@ -1318,8 +1320,9 @@ def perform_update(self, serializer):
 
     def destroy(self, request, *args, **kwargs):
         obj = self.get_object()
-        if not request.user.has_perm("unit.delete", obj):
-            self.permission_denied(request, "Can not remove string")
+        can_delete = request.user.has_perm("unit.delete", obj)
+        if not can_delete:
+            self.permission_denied(request, can_delete.reason)
         try:
             obj.translation.delete_unit(request, obj)
         except FileParseError as error:
diff --git a/weblate/auth/permissions.py b/weblate/auth/permissions.py
--- a/weblate/auth/permissions.py
+++ b/weblate/auth/permissions.py
@@ -3,6 +3,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 from django.conf import settings
+from django.utils.translation import gettext
 
 from weblate.trans.models import (
     Component,
@@ -17,6 +18,24 @@
 SPECIALS = {}
 
 
+class PermissionResult:
+    def __init__(self, reason: str = ""):
+        self.reason = reason
+
+    def __bool__(self) -> bool:
+        raise NotImplementedError
+
+
+class Allowed(PermissionResult):
+    def __bool__(self) -> bool:
+        return True
+
+
+class Denied(PermissionResult):
+    def __bool__(self) -> bool:
+        return False
+
+
 def register_perm(*perms):
     def wrap_perm(function):
         for perm in perms:
@@ -110,22 +129,26 @@ def check_can_edit(user, permission, obj, is_vote=False):
 
     # Email is needed for user to be able to edit
     if user.is_authenticated and not user.email:
-        return False
+        return Denied(gettext("Can not perform this operation without an e-mail."))
 
     if component:
         # Check component lock
         if component.locked:
-            return False
+            return Denied(gettext("This translation is currently locked."))
 
         # Check contributor agreement
         if component.agreement and not ContributorAgreement.objects.has_agreed(
             user, component
         ):
-            return False
+            return Denied(
+                gettext(
+                    "Contributing to this translation requires you agree to its contributor agreement."
+                )
+            )
 
     # Perform usual permission check
     if not check_permission(user, permission, obj):
-        return False
+        return Denied(gettext("Insufficient privileges for saving translations."))
 
     # Special check for source strings (templates)
     if (
@@ -133,11 +156,11 @@ def check_can_edit(user, permission, obj, is_vote=False):
         and translation.is_template
         and not check_permission(user, "unit.template", obj)
     ):
-        return False
+        return Denied(gettext("Insufficient privileges for editing source strings."))
 
     # Special checks for voting
     if is_vote and component and not component.suggestion_voting:
-        return False
+        return Denied(gettext("Suggestion voting is disabled."))
     if (
         not is_vote
         and translation
@@ -145,13 +168,17 @@ def check_can_edit(user, permission, obj, is_vote=False):
         and component.suggestion_autoaccept > 0
         and not check_permission(user, "unit.override", obj)
     ):
-        return False
+        return Denied(
+            gettext(
+                "This translation only accepts suggestions, and these are approved by voting."
+            )
+        )
 
     # Billing limits
     if not project.paid:
-        return False
+        return Denied(gettext("Pay the bills to unlock this project."))
 
-    return True
+    return Allowed()
 
 
 @register_perm("unit.review")
@@ -178,15 +205,20 @@ def check_edit_approved(user, permission, obj):
         obj = unit.translation
         # Read only check is unconditional as there is another one
         # in PluralTextarea.render
-        if unit.readonly or (
-            unit.approved
-            and not check_unit_review(user, "unit.review", obj, skip_enabled=True)
+        if unit.readonly:
+            return Denied(gettext("The string is read only."))
+        if unit.approved and not check_unit_review(
+            user, "unit.review", obj, skip_enabled=True
         ):
-            return False
+            return Denied(
+                gettext(
+                    "Only reviewers can change approved strings, please add a suggestion if you think the string should be changed."
+                )
+            )
     if isinstance(obj, Translation):
         component = obj.component
         if obj.is_readonly:
-            return False
+            return Denied(gettext("The translation is read only."))
     elif isinstance(obj, Component):
         component = obj
     if component is not None and component.is_glossary:
@@ -194,18 +226,22 @@ def check_edit_approved(user, permission, obj):
     return check_can_edit(user, permission, obj)
 
 
-def check_manage_units(translation: Translation, component: Component) -> bool:
+def check_manage_units(
+    translation: Translation, component: Component
+) -> PermissionResult:
     if not isinstance(component, Component):
-        return False
+        return Denied("Invalid scope")
     source = translation.is_source
     template = component.has_template()
     # Add only to source in monolingual
     if not source and template:
-        return False
+        return Denied(gettext("Add the string to the source language instead."))
     # Check if adding is generally allowed
     if not component.manage_units or (template and not component.edit_template):
-        return False
-    return True
+        return Denied(
+            gettext("Adding strings is disabled in the component configuration.")
+        )
+    return Allowed()
 
 
 @register_perm("unit.delete")
@@ -214,12 +250,13 @@ def check_unit_delete(user, permission, obj):
         obj = obj.translation
     component = obj.component
     # Check if removing is generally allowed
-    if not check_manage_units(obj, component):
-        return False
+    can_manage = check_manage_units(obj, component)
+    if not can_manage:
+        return can_manage
 
     # Does file format support removing?
     if not component.file_format_cls.can_delete_unit:
-        return False
+        return Denied(gettext("File format does not support this."))
 
     if component.is_glossary:
         permission = "glossary.delete"
@@ -230,12 +267,13 @@ def check_unit_delete(user, permission, obj):
 def check_unit_add(user, permission, translation):
     component = translation.component
     # Check if adding is generally allowed
-    if not check_manage_units(translation, component):
-        return False
+    can_manage = check_manage_units(translation, component)
+    if not can_manage:
+        return can_manage
 
     # Does file format support adding?
     if not component.file_format_cls.can_add_unit:
-        return False
+        return Denied(gettext("File format does not support this."))
 
     if component.is_glossary:
         permission = "glossary.add"


Test patch: