Repository: WeblateOrg/weblate

Issue:
API for managing permissions
**Is your feature request related to a problem? Please describe.**
Once the API for component lists is implemented (https://github.com/WeblateOrg/weblate/issues/3291) and it becomes possible to assign permissions at the component/component list level (https://github.com/WeblateOrg/weblate/issues/3646) it would be great if that could be done programmatically via the RESTful API.

**Describe the solution you'd like**
A set of API endpoints, which allows for managing project/component list/component level permissions for given users. At the very least it should be possible to grant access to each of those items to a user if a project is private.


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
@@ -143,9 +143,20 @@ class Meta:
     def to_representation(self, value):
         return value.codename
 
+    def get_queryset(self):
+        return Permission.objects.all()
+
+    def to_internal_value(self, data):
+        check_query = Permission.objects.filter(codename=data)
+        if not check_query.exists():
+            raise serializers.ValidationError(
+                "Permission with this codename was not found."
+            )
+        return data
+
 
 class RoleSerializer(serializers.ModelSerializer):
-    permissions = PermissionSerializer(read_only=True, many=True)
+    permissions = PermissionSerializer(many=True)
 
     class Meta:
         model = Role
@@ -156,17 +167,42 @@ class Meta:
         )
         extra_kwargs = {"url": {"view_name": "api:role-detail", "lookup_field": "id"}}
 
+    def create(self, validated_data):
+        permissions_validated = validated_data.pop("permissions", [])
+        role = Role.objects.create(**validated_data)
+        role.permissions.add(
+            *Permission.objects.filter(codename__in=permissions_validated)
+        )
+        return role
+
+    def update(self, instance, validated_data):
+        permissions_validated = validated_data.pop("permissions", [])
+        instance.name = validated_data.get("name", instance.name)
+        instance.save()
+        if self.partial:
+            instance.permissions.add(
+                *Permission.objects.filter(codename__in=permissions_validated)
+            )
+        else:
+            instance.permissions.set(
+                Permission.objects.filter(codename__in=permissions_validated)
+            )
+        return instance
+
 
 class GroupSerializer(serializers.ModelSerializer):
     roles = serializers.HyperlinkedIdentityField(
         view_name="api:role-detail", lookup_field="id", many=True, read_only=True,
     )
+    languages = serializers.HyperlinkedIdentityField(
+        view_name="api:language-detail", lookup_field="code", many=True, read_only=True,
+    )
     projects = serializers.HyperlinkedIdentityField(
         view_name="api:project-detail", lookup_field="slug", many=True, read_only=True,
     )
     componentlist = serializers.HyperlinkedRelatedField(
         view_name="api:componentlist-detail",
-        lookup_field="_slug",
+        lookup_field="slug",
         many=False,
         read_only=True,
     )
@@ -185,6 +221,7 @@ class Meta:
             "language_selection",
             "url",
             "roles",
+            "languages",
             "projects",
             "componentlist",
             "components",
diff --git a/weblate/api/views.py b/weblate/api/views.py
--- a/weblate/api/views.py
+++ b/weblate/api/views.py
@@ -339,9 +339,194 @@ def destroy(self, request, *args, **kwargs):
         self.perm_check(request)
         return super().destroy(request, *args, **kwargs)
 
+    @action(
+        detail=True, methods=["post"],
+    )
+    def roles(self, request, **kwargs):
+        obj = self.get_object()
+        self.perm_check(request)
 
-class RoleViewSet(viewsets.ReadOnlyModelViewSet):
-    """Languages API."""
+        if "role_id" not in request.data:
+            raise ParseError("Missing role_id parameter")
+
+        try:
+            role = Role.objects.get(pk=int(request.data["role_id"]),)
+        except (Role.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        obj.roles.add(role)
+        serializer = self.serializer_class(obj, context={"request": request})
+
+        return Response(serializer.data, status=status.HTTP_200_OK)
+
+    @action(
+        detail=True, methods=["post"],
+    )
+    def languages(self, request, **kwargs):
+        obj = self.get_object()
+        self.perm_check(request)
+
+        if "language_code" not in request.data:
+            raise ParseError("Missing language_code parameter")
+
+        try:
+            language = Language.objects.get(code=request.data["language_code"])
+        except (Language.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        obj.languages.add(language)
+        serializer = self.serializer_class(obj, context={"request": request})
+
+        return Response(serializer.data, status=status.HTTP_200_OK)
+
+    @action(
+        detail=True, methods=["delete"], url_path="languages/(?P<language_code>[^/.]+)"
+    )
+    def delete_languages(self, request, id, language_code):
+        obj = self.get_object()
+        self.perm_check(request)
+
+        try:
+            language = Language.objects.get(code=language_code)
+        except (Language.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+        obj.languages.remove(language)
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+    @action(
+        detail=True, methods=["post"],
+    )
+    def projects(self, request, **kwargs):
+        obj = self.get_object()
+        self.perm_check(request)
+
+        if "project_id" not in request.data:
+            raise ParseError("Missing project_id parameter")
+
+        try:
+            project = Project.objects.get(pk=int(request.data["project_id"]),)
+        except (Project.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+        obj.projects.add(project)
+        serializer = self.serializer_class(obj, context={"request": request})
+
+        return Response(serializer.data, status=status.HTTP_200_OK)
+
+    @action(detail=True, methods=["delete"], url_path="projects/(?P<project_id>[^/.]+)")
+    def delete_projects(self, request, id, project_id):
+        obj = self.get_object()
+        self.perm_check(request)
+
+        try:
+            project = Project.objects.get(pk=int(project_id))
+        except (Project.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+        obj.projects.remove(project)
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+    @action(
+        detail=True, methods=["post"],
+    )
+    def componentlist(self, request, **kwargs):
+        obj = self.get_object()
+        self.perm_check(request)
+
+        if "component_list_id" not in request.data:
+            raise ParseError("Missing component_list_id parameter")
+
+        try:
+            component_list = ComponentList.objects.get(
+                pk=int(request.data["component_list_id"]),
+            )
+        except (ComponentList.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+        obj.componentlist = component_list
+        obj.save()
+        serializer = self.serializer_class(obj, context={"request": request})
+
+        return Response(serializer.data, status=status.HTTP_200_OK)
+
+    @action(
+        detail=True,
+        methods=["delete"],
+        url_path="componentlist/(?P<component_list_id>[^/.]+)",
+    )
+    def delete_componentlist(self, request, id, component_list_id):
+        obj = self.get_object()
+        self.perm_check(request)
+        try:
+            ComponentList.objects.get(pk=int(component_list_id),)
+        except (ComponentList.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+        obj.componentlist = None
+        obj.save()
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+    @action(
+        detail=True, methods=["post"],
+    )
+    def components(self, request, **kwargs):
+        obj = self.get_object()
+        self.perm_check(request)
+        if "component_id" not in request.data:
+            raise ParseError("Missing component_id parameter")
+
+        try:
+            component = Component.objects.get(
+                project_id__in=self.request.user.allowed_project_ids,
+                pk=int(request.data["component_id"]),
+            )
+        except (Component.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+        obj.components.add(component)
+        serializer = self.serializer_class(obj, context={"request": request})
+
+        return Response(serializer.data, status=status.HTTP_200_OK)
+
+    @action(
+        detail=True, methods=["delete"], url_path="components/(?P<component_id>[^/.]+)"
+    )
+    def delete_components(self, request, id, component_id):
+        obj = self.get_object()
+        self.perm_check(request)
+
+        try:
+            component = Component.objects.get(pk=int(component_id),)
+        except (Component.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+        obj.components.remove(component)
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class RoleViewSet(viewsets.ModelViewSet):
+    """Roles API."""
 
     queryset = Role.objects.none()
     serializer_class = RoleSerializer
@@ -356,6 +541,22 @@ def get_queryset(self):
             .all()
         )
 
+    def perm_check(self, request):
+        if not request.user.has_perm("role.edit"):
+            self.permission_denied(request, message="Can not manage roles")
+
+    def update(self, request, *args, **kwargs):
+        self.perm_check(request)
+        return super().update(request, *args, **kwargs)
+
+    def create(self, request, *args, **kwargs):
+        self.perm_check(request)
+        return super().create(request, *args, **kwargs)
+
+    def destroy(self, request, *args, **kwargs):
+        self.perm_check(request)
+        return super().destroy(request, *args, **kwargs)
+
 
 class ProjectViewSet(WeblateViewSet, CreateModelMixin, DestroyModelMixin):
     """Translation projects 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
@@ -23,7 +23,7 @@
 from rest_framework.exceptions import ErrorDetail
 from rest_framework.test import APITestCase
 
-from weblate.auth.models import Group, User
+from weblate.auth.models import Group, Role, User
 from weblate.screenshots.models import Screenshot
 from weblate.trans.models import (
     Change,
@@ -266,6 +266,280 @@ def test_create(self):
         )
         self.assertEqual(Group.objects.count(), 7)
 
+    def test_add_role(self):
+        role = Role.objects.get(pk=1)
+        self.do_request(
+            "api:group-roles",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            code=403,
+            request={"role_id": role.id},
+        )
+        self.do_request(
+            "api:group-roles",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=400,
+            request={"role_id": -1},
+        )
+        self.do_request(
+            "api:group-roles",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=200,
+            request={"role_id": role.id},
+        )
+
+    def test_add_component(self):
+        self.do_request(
+            "api:group-components",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            code=403,
+            request={"component_id": self.component.pk},
+        )
+        self.do_request(
+            "api:group-components",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=400,
+            request={"component_id": -1},
+        )
+        self.do_request(
+            "api:group-components",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=200,
+            request={"component_id": self.component.pk},
+        )
+
+    def test_remove_component(self):
+        self.do_request(
+            "api:group-components",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=200,
+            request={"component_id": self.component.pk},
+        )
+        self.do_request(
+            "api:group-delete-components",
+            kwargs={
+                "id": Group.objects.get(name="Users").id,
+                "component_id": self.component.pk,
+            },
+            method="delete",
+            code=403,
+        )
+        self.do_request(
+            "api:group-delete-components",
+            kwargs={"id": Group.objects.get(name="Users").id, "component_id": -1},
+            method="delete",
+            superuser=True,
+            code=400,
+        )
+        self.do_request(
+            "api:group-delete-components",
+            kwargs={
+                "id": Group.objects.get(name="Users").id,
+                "component_id": self.component.pk,
+            },
+            method="delete",
+            superuser=True,
+            code=204,
+        )
+        self.assertEqual(Group.objects.get(name="Users").components.count(), 0)
+
+    def test_add_project(self):
+        self.do_request(
+            "api:group-projects",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            code=403,
+            request={"project_id": Project.objects.get(slug="test").pk},
+        )
+        self.do_request(
+            "api:group-projects",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=400,
+            request={"project_id": -1},
+        )
+        self.do_request(
+            "api:group-projects",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=200,
+            request={"project_id": Project.objects.get(slug="test").pk},
+        )
+
+    def test_remove_project(self):
+        self.do_request(
+            "api:group-projects",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=200,
+            request={"project_id": Project.objects.get(slug="test").pk},
+        )
+        self.do_request(
+            "api:group-delete-projects",
+            kwargs={
+                "id": Group.objects.get(name="Users").id,
+                "project_id": Project.objects.get(slug="test").pk,
+            },
+            method="delete",
+            code=403,
+        )
+        self.do_request(
+            "api:group-delete-projects",
+            kwargs={"id": Group.objects.get(name="Users").id, "project_id": -1},
+            method="delete",
+            superuser=True,
+            code=400,
+        )
+        self.do_request(
+            "api:group-delete-projects",
+            kwargs={
+                "id": Group.objects.get(name="Users").id,
+                "project_id": Project.objects.get(slug="test").pk,
+            },
+            method="delete",
+            superuser=True,
+            code=204,
+        )
+        self.assertEqual(Group.objects.get(name="Users").projects.count(), 0)
+
+    def test_add_language(self):
+        self.do_request(
+            "api:group-languages",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            code=403,
+            request={"language_code": "cs"},
+        )
+        self.do_request(
+            "api:group-languages",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=400,
+            request={"language_code": "invalid"},
+        )
+        self.do_request(
+            "api:group-languages",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=200,
+            request={"language_code": "cs"},
+        )
+
+    def test_remove_language(self):
+        self.do_request(
+            "api:group-languages",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=200,
+            request={"language_code": "cs"},
+        )
+        self.do_request(
+            "api:group-delete-languages",
+            kwargs={"id": Group.objects.get(name="Users").id, "language_code": "cs"},
+            method="delete",
+            code=403,
+        )
+        self.do_request(
+            "api:group-delete-languages",
+            kwargs={
+                "id": Group.objects.get(name="Users").id,
+                "language_code": "invalid",
+            },
+            method="delete",
+            superuser=True,
+            code=400,
+        )
+        self.do_request(
+            "api:group-delete-languages",
+            kwargs={"id": Group.objects.get(name="Users").id, "language_code": "cs"},
+            method="delete",
+            superuser=True,
+            code=204,
+        )
+
+    def test_add_componentlist(self):
+        clist = ComponentList.objects.create(name="Name", slug="name")
+        clist.autocomponentlist_set.create()
+        self.do_request(
+            "api:group-componentlist",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            code=403,
+            request={"component_list_id": ComponentList.objects.get().pk},
+        )
+        self.do_request(
+            "api:group-componentlist",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=400,
+            request={"component_list_id": -1},
+        )
+        self.do_request(
+            "api:group-componentlist",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=200,
+            request={"component_list_id": ComponentList.objects.get().pk},
+        )
+
+    def test_remove_componentlist(self):
+        clist = ComponentList.objects.create(name="Name", slug="name")
+        clist.autocomponentlist_set.create()
+        self.do_request(
+            "api:group-componentlist",
+            kwargs={"id": Group.objects.get(name="Users").id},
+            method="post",
+            superuser=True,
+            code=200,
+            request={"component_list_id": ComponentList.objects.get().pk},
+        )
+        self.do_request(
+            "api:group-delete-componentlist",
+            kwargs={
+                "id": Group.objects.get(name="Users").id,
+                "component_list_id": ComponentList.objects.get().pk,
+            },
+            method="delete",
+            code=403,
+        )
+        self.do_request(
+            "api:group-delete-componentlist",
+            kwargs={"id": Group.objects.get(name="Users").id, "component_list_id": -1},
+            method="delete",
+            superuser=True,
+            code=400,
+        )
+        self.do_request(
+            "api:group-delete-componentlist",
+            kwargs={
+                "id": Group.objects.get(name="Users").id,
+                "component_list_id": ComponentList.objects.get().pk,
+            },
+            method="delete",
+            superuser=True,
+            code=204,
+        )
+        self.assertIsNone(Group.objects.get(name="Users").componentlist)
+
     def test_delete(self):
         self.do_request(
             "api:group-detail",
@@ -332,6 +606,115 @@ def test_get_role(self):
         response = self.client.get(reverse("api:role-detail", kwargs={"id": 1}))
         self.assertEqual(response.data["name"], "Add suggestion")
 
+    def test_create(self):
+        self.do_request(
+            "api:role-list", method="post", code=403,
+        )
+        self.do_request(
+            "api:role-list",
+            method="post",
+            superuser=True,
+            code=400,
+            format="json",
+            request={"name": "Role", "permissions": ["invalid.codename"]},
+        )
+        self.do_request(
+            "api:role-list",
+            method="post",
+            superuser=True,
+            code=201,
+            format="json",
+            request={"name": "Role", "permissions": ["suggestion.add", "comment.add"]},
+        )
+        self.assertEqual(Role.objects.count(), 14)
+        self.assertEqual(Role.objects.get(name="Role").permissions.count(), 2)
+
+    def test_delete(self):
+        self.do_request(
+            "api:role-detail",
+            kwargs={"id": Role.objects.all()[0].pk},
+            method="delete",
+            superuser=True,
+            code=204,
+        )
+        self.assertEqual(Role.objects.count(), 12)
+
+    def test_put(self):
+        self.do_request(
+            "api:role-detail",
+            kwargs={"id": Role.objects.order_by("id").all()[0].pk},
+            method="put",
+            code=403,
+        )
+        self.do_request(
+            "api:role-detail",
+            kwargs={"id": Role.objects.order_by("id").all()[0].pk},
+            method="put",
+            superuser=True,
+            code=200,
+            format="json",
+            request={"name": "Role", "permissions": ["suggestion.add"]},
+        )
+        self.assertEqual(Role.objects.order_by("id").all()[0].name, "Role")
+        self.assertEqual(Role.objects.order_by("id").all()[0].permissions.count(), 1)
+        self.assertEqual(
+            Role.objects.order_by("id").all()[0].permissions.get().codename,
+            "suggestion.add",
+        )
+        # Add permission
+        self.do_request(
+            "api:role-detail",
+            kwargs={"id": Role.objects.order_by("id").all()[0].pk},
+            method="put",
+            superuser=True,
+            code=200,
+            format="json",
+            request={"name": "Role", "permissions": ["suggestion.add", "comment.add"]},
+        )
+        self.assertEqual(Role.objects.order_by("id").all()[0].permissions.count(), 2)
+        # Remove permission
+        self.do_request(
+            "api:role-detail",
+            kwargs={"id": Role.objects.order_by("id").all()[0].pk},
+            method="put",
+            superuser=True,
+            code=200,
+            format="json",
+            request={"name": "Role", "permissions": ["comment.add"]},
+        )
+        self.assertEqual(Role.objects.order_by("id").all()[0].permissions.count(), 1)
+        self.assertEqual(
+            Role.objects.order_by("id").all()[0].permissions.get().codename,
+            "comment.add",
+        )
+
+    def test_patch(self):
+        self.do_request(
+            "api:role-detail",
+            kwargs={"id": Role.objects.order_by("id").all()[0].pk},
+            method="patch",
+            code=403,
+        )
+        self.do_request(
+            "api:role-detail",
+            kwargs={"id": Role.objects.order_by("id").all()[0].pk},
+            method="patch",
+            superuser=True,
+            code=200,
+            request={"name": "New Role"},
+        )
+        self.assertEqual(Role.objects.order_by("id").all()[0].name, "New Role")
+        self.do_request(
+            "api:role-detail",
+            kwargs={"id": Role.objects.order_by("id").all()[0].pk},
+            method="patch",
+            superuser=True,
+            code=200,
+            format="json",
+            request={"permissions": ["comment.add"]},
+        )
+        self.assertEqual(Role.objects.order_by("id").all()[0].permissions.count(), 2)
+
 
 class ProjectAPITest(APIBaseTest):
     def test_list_projects(self):