Repository: WeblateOrg/weblate

Issue:
Extend screenshots API [$100 awarded]
**Is your feature request related to a problem? Please describe.**
The screenshots API is currently limited to listing screenshots and does not support adding new ones.

**Describe the solution you'd like**
Extend API to allow:

* List screenshots per component
* Add new screenshots
* Associate strings with screenshots

**Additional context**
https://docs.weblate.org/en/latest/api.html#screenshots

Blocked by #2831



Discussion:
Hi @nijel , having a look at this today. May I please get assigned to it?
You're welcome, I've just assigned it to you.
Thank you
Hi again @nijel , I've finally had a look and come up with some possible solutions. However, I have a question regarding 

> * List screenshots per component

Given the current API organisation and the viewset pattern implemented. Would you also find it fit to add this under the translations component API with the request pattern
`GET /api/components/(string:project)/(string:component)/screenshots/` 
instead of
`GET /api/screenshots/(string:project)/(string:component)/` 
 as per my initial look at this would require fewer changes and subsequently complexity to the API views implementation. 
What do you think?
>  `GET /api/components/(string:project)/(string:component)/screenshots/`

Yes, this makes sense to use this approach.
How are you getting on @brymut?
Hi @jammmet, still working on some test cases. Unfortunately got overwhelmed with other stuff over the past week but should be submitting a pull request by Thursday.

Edit: Apologies for the delay, getting on it today.
Hi @brymut, I would be happy to test if needed.
> Thursday

Great. Stay safe in Nairobi (I'm based here). Let me know when you have something to test (I posted the bounty)

Resolving patch:
diff --git a/weblate/api/views.py b/weblate/api/views.py
--- a/weblate/api/views.py
+++ b/weblate/api/views.py
@@ -458,6 +458,17 @@ def changes(self, request, **kwargs):
 
         return self.get_paginated_response(serializer.data)
 
+    @action(detail=True, methods=["get"])
+    def screenshots(self, request, **kwargs):
+        obj = self.get_object()
+
+        queryset = Screenshot.objects.filter(component=obj)
+        page = self.paginate_queryset(queryset)
+
+        serializer = ScreenshotSerializer(page, many=True, context={"request": request})
+
+        return self.get_paginated_response(serializer.data)
+
     def destroy(self, request, *args, **kwargs):
         instance = self.get_object()
         if not request.user.has_perm("component.edit", instance):
@@ -606,7 +617,7 @@ def get_queryset(self):
         ).order_by("id")
 
 
-class ScreenshotViewSet(DownloadViewSet):
+class ScreenshotViewSet(DownloadViewSet, CreateModelMixin):
     """Screenshots API."""
 
     queryset = Screenshot.objects.none()
@@ -645,6 +656,65 @@ def file(self, request, **kwargs):
 
         return Response(data={"result": True})
 
+    @action(
+        detail=True, methods=["post"],
+    )
+    def units(self, request, **kwargs):
+        obj = self.get_object()
+
+        if not request.user.has_perm("screenshot.edit", obj.component):
+            raise PermissionDenied()
+
+        if "unit_pk" not in request.data:
+            raise ParseError("Missing unit_pk parameter")
+
+        try:
+            source_string = obj.component.source_translation.unit_set.get(
+                pk=int(request.data["unit_pk"])
+            )
+        except (Unit.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        obj.units.add(source_string)
+        serializer = ScreenshotSerializer(obj, context={"request": request})
+
+        return Response(serializer.data, status=status.HTTP_200_OK,)
+
+    def create(self, request, *args, **kwargs):
+        required_params = ["name", "image", "project_slug", "component_slug"]
+        for param in required_params:
+            if param not in request.data:
+                raise ParseError("Missing {param} parameter".format(param=param))
+
+        try:
+            project = request.user.allowed_projects.get(
+                slug=request.data["project_slug"]
+            )
+            component = Component.objects.filter(project=project).get(
+                slug=request.data["component_slug"]
+            )
+        except (Project.DoesNotExist, Component.DoesNotExist, ValueError) as error:
+            return Response(
+                data={"result": "Unsuccessful", "detail": force_str(error)},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        if not request.user.has_perm("screenshot.add", component):
+            self.permission_denied(request, message="Can not add screenshot.")
+
+        with transaction.atomic():
+            serializer = ScreenshotSerializer(
+                data=request.data, context={"request": request}
+            )
+            serializer.is_valid(raise_exception=True)
+            serializer.save(
+                component=component, user=request.user, image=request.data["image"]
+            )
+            return Response(serializer.data, status=status.HTTP_201_CREATED,)
+
 
 class ChangeViewSet(viewsets.ReadOnlyModelViewSet):
     """Changes 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
@@ -20,6 +20,7 @@
 
 from django.core.files import File
 from django.urls import reverse
+from rest_framework.exceptions import ErrorDetail
 from rest_framework.test import APITestCase
 
 from weblate.auth.models import Group, User
@@ -247,6 +248,12 @@ def test_create_component(self):
 
 
 class ComponentAPITest(APIBaseTest):
+    def setUp(self):
+        super().setUp()
+        shot = Screenshot.objects.create(name="Obrazek", component=self.component)
+        with open(TEST_SCREENSHOT, "rb") as handle:
+            shot.image.save("screenshot.png", File(handle))
+
     def test_list_components(self):
         response = self.client.get(reverse("api:component-list"))
         self.assertEqual(response.data["count"], 1)
@@ -343,6 +350,11 @@ def test_changes(self):
         request = self.do_request("api:component-changes", self.component_kwargs)
         self.assertEqual(request.data["count"], 14)
 
+    def test_screenshots(self):
+        request = self.do_request("api:component-screenshots", self.component_kwargs)
+        self.assertEqual(request.data["count"], 1)
+        self.assertEqual(request.data["results"][0]["name"], "Obrazek")
+
     def test_delete(self):
         self.do_request(
             "api:component-detail", self.component_kwargs, method="delete", code=403
@@ -668,6 +680,113 @@ def test_upload_denied(self):
     def test_upload_invalid(self):
         self.test_upload(True, 400, TEST_PO)
 
+    def test_create(self):
+        with open(TEST_SCREENSHOT, "rb") as handle:
+            self.do_request(
+                "api:screenshot-list",
+                method="post",
+                code=403,
+                request={
+                    "name": "Test create screenshot",
+                    "project_slug": "test",
+                    "component_slug": "test",
+                    "image": File(handle),
+                },
+            )
+            self.do_request(
+                "api:screenshot-list",
+                method="post",
+                code=400,
+                superuser=True,
+                data={
+                    "detail": ErrorDetail(
+                        string="Missing name parameter", code="parse_error"
+                    )
+                },
+                request={
+                    "project_slug": "test",
+                    "component_slug": "test",
+                    "image": File(handle),
+                },
+            )
+            self.do_request(
+                "api:screenshot-list",
+                method="post",
+                code=400,
+                superuser=True,
+                data={
+                    "result": "Unsuccessful",
+                    "detail": "Project matching query does not exist.",
+                },
+                request={
+                    "name": "Test create screenshot",
+                    "project_slug": "aaa",
+                    "component_slug": "test",
+                    "image": File(handle),
+                },
+            )
+            self.do_request(
+                "api:screenshot-list",
+                method="post",
+                code=400,
+                superuser=True,
+                data={
+                    "result": "Unsuccessful",
+                    "detail": "Component matching query does not exist.",
+                },
+                request={
+                    "name": "Test create screenshot",
+                    "project_slug": "test",
+                    "component_slug": "bbb",
+                    "image": File(handle),
+                },
+            )
+            self.do_request(
+                "api:screenshot-list",
+                method="post",
+                code=201,
+                superuser=True,
+                request={
+                    "name": "Test create screenshot",
+                    "project_slug": "test",
+                    "component_slug": "test",
+                    "image": File(handle),
+                },
+            )
+        self.assertEqual(Screenshot.objects.count(), 2)
+
+    def test_units_denied(self):
+        unit = self.component.source_translation.unit_set.all()[0]
+        response = self.client.post(
+            reverse(
+                "api:screenshot-units", kwargs={"pk": Screenshot.objects.all()[0].pk}
+            ),
+            {"unit_pk": unit.pk},
+        )
+        self.assertEqual(response.status_code, 401)
+
+    def test_units_invalid(self):
+        self.authenticate(True)
+        response = self.client.post(
+            reverse(
+                "api:screenshot-units", kwargs={"pk": Screenshot.objects.all()[0].pk}
+            ),
+            {"unit_pk": -1},
+        )
+        self.assertEqual(response.status_code, 400)
+
+    def test_units(self):
+        self.authenticate(True)
+        unit = self.component.source_translation.unit_set.all()[0]
+        response = self.client.post(
+            reverse(
+                "api:screenshot-units", kwargs={"pk": Screenshot.objects.all()[0].pk}
+            ),
+            {"unit_pk": unit.pk},
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(str(unit.pk), response.data["units"][0])
+
 
 class ChangeAPITest(APIBaseTest):
     def test_list_changes(self):