Repository: WeblateOrg/weblate

Issue:
Add API for addons
There is current ly no API for addons, it should allow:

* Listing enabled addons
* Adding new addon (with configuration)
* Changing configuration of existing addons
* Removing addons

> The addon looks promising. I think this would still need manual intervention to enable the addon on each translation component. I am trying to reduce the manual process related to setting up of a translation component to none or minimum. 
Could we enable and configure this addon via APIs? That would help even more since we wouldn't have to trigger the auto-translation everytime a string is added.

_Originally posted by @jojurajan in https://github.com/WeblateOrg/weblate/issues/3930#issuecomment-633831015_


Discussion:
Related to https://github.com/WeblateOrg/weblate/issues/3930
@nijel I can have a look at it. 
@prateekj117 You're welcome.

Resolving patch:
diff --git a/weblate/api/serializers.py b/weblate/api/serializers.py
--- a/weblate/api/serializers.py
+++ b/weblate/api/serializers.py
@@ -23,6 +23,7 @@
 from rest_framework import serializers
 
 from weblate.accounts.models import Subscription
+from weblate.addons.models import ADDONS, Addon
 from weblate.auth.models import Group, Permission, Role, User
 from weblate.glossary.models import Glossary, Term
 from weblate.lang.models import Language, Plural
@@ -430,6 +431,13 @@ class ComponentSerializer(RemovableSerializer):
 
     task_url = RelatedTaskField(lookup_field="background_task_id")
 
+    addons = serializers.HyperlinkedIdentityField(
+        view_name="api:addon-detail",
+        source="addon_set",
+        many=True,
+        read_only=True,
+    )
+
     class Meta:
         model = Component
         fields = (
@@ -486,6 +494,7 @@ class Meta:
             "variant_regex",
             "zipfile",
             "docfile",
+            "addons",
         )
         extra_kwargs = {
             "url": {
@@ -1031,3 +1040,64 @@ class Meta:
         extra_kwargs = {
             "url": {"view_name": "api:componentlist-detail", "lookup_field": "slug"}
         }
+
+
+class AddonSerializer(serializers.ModelSerializer):
+    component = MultiFieldHyperlinkedIdentityField(
+        view_name="api:component-detail",
+        lookup_field=("component__project__slug", "component__slug"),
+        read_only=True,
+        strip_parts=1,
+    )
+    configuration = serializers.JSONField(required=False)
+
+    class Meta:
+        model = Addon
+        fields = (
+            "component",
+            "name",
+            "id",
+            "configuration",
+            "url",
+        )
+        extra_kwargs = {"url": {"view_name": "api:addon-detail"}}
+
+    def validate(self, attrs):
+        instance = self.instance
+        try:
+            name = attrs["name"]
+        except KeyError:
+            if self.partial and self.instance:
+                name = self.instance.name
+            else:
+                raise serializers.ValidationError("Can not change addon name")
+        if instance:
+            # Update
+            component = instance.component
+        else:
+            # Create
+            component = self._context["component"]
+        # This could probably work, but it safer not to allow it
+        if instance and instance.name != name:
+            raise serializers.ValidationError("Can not change addon name")
+        try:
+            addon_class = ADDONS[name]
+        except KeyError:
+            raise serializers.ValidationError(f"Addon not found: {name}")
+        addon = addon_class()
+        if not addon.can_install(component, None):
+            raise serializers.ValidationError(
+                f"could not enable addon {name}, not compatible"
+            )
+        if addon.has_settings and "configuration" in attrs:
+            form = addon.get_add_form(None, component, data=attrs["configuration"])
+            form.is_valid()
+            if not form.is_valid():
+                for error in form.non_field_errors():
+                    raise serializers.ValidationError(error)
+                for field in form:
+                    for error in field.errors:
+                        raise serializers.ValidationError(
+                            f"Error in {field.name}: {error}"
+                        )
+        return attrs
diff --git a/weblate/api/urls.py b/weblate/api/urls.py
--- a/weblate/api/urls.py
+++ b/weblate/api/urls.py
@@ -22,6 +22,7 @@
 
 from weblate.api.routers import WeblateRouter
 from weblate.api.views import (
+    AddonViewSet,
     ChangeViewSet,
     ComponentListViewSet,
     ComponentViewSet,
@@ -53,6 +54,7 @@
 router.register("screenshots", ScreenshotViewSet)
 router.register("glossary", GlossaryViewSet)
 router.register("tasks", TasksViewSet, "task")
+router.register("addons", AddonViewSet)
 
 
 # Wire up our API using automatic URL routing.
diff --git a/weblate/api/views.py b/weblate/api/views.py
--- a/weblate/api/views.py
+++ b/weblate/api/views.py
@@ -46,7 +46,9 @@
 
 from weblate.accounts.models import Subscription
 from weblate.accounts.utils import remove_user
+from weblate.addons.models import Addon
 from weblate.api.serializers import (
+    AddonSerializer,
     BasicUserSerializer,
     ChangeSerializer,
     ComponentListSerializer,
@@ -977,6 +979,20 @@ def translations(self, request, **kwargs):
 
         return self.get_paginated_response(serializer.data)
 
+    @action(detail=True, methods=["post"])
+    def addons(self, request, **kwargs):
+        obj = self.get_object()
+
+        if not request.user.has_perm("component.edit", obj):
+            self.permission_denied(request, "Can not create addon")
+
+        serializer = AddonSerializer(
+            data=request.data, context={"request": request, "component": obj}
+        )
+        serializer.is_valid(raise_exception=True)
+        serializer.save(component=obj)
+        return Response(serializer.data, status=HTTP_201_CREATED)
+
     @action(detail=True, methods=["get"])
     def statistics(self, request, **kwargs):
         obj = self.get_object()
@@ -1592,3 +1608,23 @@ def destroy(self, request, pk=None):
             if component.background_task_id == pk:
                 component.delete_background_task()
         return Response(status=HTTP_204_NO_CONTENT)
+
+
+class AddonViewSet(viewsets.ReadOnlyModelViewSet, UpdateModelMixin, DestroyModelMixin):
+
+    queryset = Addon.objects.all()
+    serializer_class = AddonSerializer
+
+    def perm_check(self, request, instance: Addon):
+        if not request.user.has_perm("component.edit", instance.component):
+            self.permission_denied(request, "Can not manage addons")
+
+    def update(self, request, *args, **kwargs):
+        instance = self.get_object()
+        self.perm_check(request, instance)
+        return super().update(request, *args, **kwargs)
+
+    def destroy(self, request, *args, **kwargs):
+        instance = self.get_object()
+        self.perm_check(request, instance)
+        return super().destroy(request, *args, **kwargs)


Test patch:
diff --git a/weblate/api/tests.py b/weblate/api/tests.py
--- a/weblate/api/tests.py
+++ b/weblate/api/tests.py
@@ -3000,3 +3000,81 @@ def test_patch(self):
             request={"name": "other"},
         )
         self.assertEqual(ComponentList.objects.get().name, "other")
+
+
+class AddonAPITest(APIBaseTest):
+    def create_addon(
+        self, superuser=True, code=201, name="weblate.gettext.linguas", **request
+    ):
+        request["name"] = name
+        return self.do_request(
+            "api:component-addons",
+            kwargs=self.component_kwargs,
+            method="post",
+            code=code,
+            superuser=superuser,
+            format="json",
+            request=request,
+        )
+
+    def test_create(self):
+        # Not authenticated user
+        response = self.create_addon(code=403, superuser=False)
+        self.assertFalse(self.component.addon_set.exists())
+        # Non existing addon
+        response = self.create_addon(name="invalid.addon", code=400)
+        self.assertFalse(self.component.addon_set.exists())
+        # Not compatible addon
+        response = self.create_addon(name="weblate.resx.update", code=400)
+        self.assertFalse(self.component.addon_set.exists())
+        # Success
+        response = self.create_addon()
+        self.assertTrue(
+            self.component.addon_set.filter(pk=response.data["id"]).exists()
+        )
+
+    def test_delete(self):
+        response = self.create_addon()
+        self.do_request(
+            "api:addon-detail",
+            kwargs={"pk": response.data["id"]},
+            method="delete",
+            code=403,
+        )
+        self.do_request(
+            "api:addon-detail",
+            kwargs={"pk": response.data["id"]},
+            method="delete",
+            superuser=True,
+            code=204,
+        )
+
+    def test_configuration(self):
+        self.create_addon(
+            name="weblate.gettext.mo", configuration={"path": "{{var}}"}, code=400
+        )
+
+    def test_edit(self):
+        initial = {"path": "{{ filename|stripext }}.mo"}
+        expected = {"path": "{{ language_code }}.mo"}
+        response = self.create_addon(name="weblate.gettext.mo", configuration=initial)
+        self.assertEqual(self.component.addon_set.get().configuration, initial)
+        self.do_request(
+            "api:addon-detail",
+            kwargs={"pk": response.data["id"]},
+            method="patch",
+            code=403,
+            format="json",
+            request={"configuration": expected},
+        )
+        self.assertEqual(self.component.addon_set.get().configuration, initial)
+        self.do_request(
+            "api:addon-detail",
+            kwargs={"pk": response.data["id"]},
+            method="patch",
+            superuser=True,
+            code=200,
+            format="json",
+            request={"configuration": expected},
+        )
+        self.assertEqual(self.component.addon_set.get().configuration, expected)