Repository: SaaShup/netbox-docker-plugin (language: python)

Issue:
:sparkles: Add ability to "Force pull" an image
## Decision Record

In order to be able to force pull an existing docker image, we need to add a new API endpoint `/api/plugins/docker/images/{pk}/force_pull` and a button in the Image detail view to call this API endpoint.

The endpoint shall POST the image with an extra field `force=True` to the agent.

![screenshot](https://github.com/user-attachments/assets/7ae7787b-1cab-4feb-ab4d-fc7c6773e692)

## Changes

 - [x] :sparkles: Add API endpoint
 - [x] :lipstick: Add button in Image detail view
 - [x] :bookmark: v1.13.0


Discussion / hints:
# ☂️ Python Coverage
> current status: ✅
## Overall Coverage
| Lines | Covered | Coverage | Threshold | Status |
| :---: | :-----: | :------: | :-------: | :----: |
|  2327 |   2287  |    98%   |    90%    |    ✅   |
## New Files
No new covered files...
## Modified Files
No covered modified files...


> **updated for commit: `bb03cc6` by [action](https://github.com/marketplace/actions/python-coverage)🐍**

Fix patch:
diff --git a/netbox_docker_plugin/__init__.py b/netbox_docker_plugin/__init__.py
index df893ef..db18c84 100644
--- a/netbox_docker_plugin/__init__.py
+++ b/netbox_docker_plugin/__init__.py
@@ -10,7 +10,7 @@ class NetBoxDockerConfig(PluginConfig):
     name = "netbox_docker_plugin"
     verbose_name = " NetBox Docker Plugin"
     description = "Manage Docker"
-    version = "2.2.0"
+    version = "2.3.0"
     base_url = "docker"
     min_version = "4.0.0"
     author= "Vincent Simonin <vincent@saashup.com>, David Delassus <david.jose.delassus@gmail.com>"
diff --git a/netbox_docker_plugin/api/views.py b/netbox_docker_plugin/api/views.py
index 29445f9..cb4a220 100644
--- a/netbox_docker_plugin/api/views.py
+++ b/netbox_docker_plugin/api/views.py
@@ -72,6 +72,47 @@ class ImageViewSet(NetBoxModelViewSet):
     serializer_class = ImageSerializer
     http_method_names = ["get", "post", "patch", "delete", "options"]
 
+    @extend_schema(
+        operation_id="plugins_docker_image_force_pull",
+        responses={
+            (200, "application/json"): OpenApiResponse(response=str),
+            (502, "application/json"): OpenApiResponse(response=str),
+        },
+    )
+    @action(
+        detail=True,
+        methods=["post"],
+        renderer_classes=[JSONRenderer],
+    )
+    def force_pull(self, _request, **_kwargs):
+        """ Force pull an existing image """
+
+        image: Image = self.get_object()
+        agent_url = image.host.endpoint
+
+        url = f"{agent_url}/api/engine/images"
+
+        try:
+            serializer = self.get_serializer(image)
+            data = serializer.data
+            data["force"] = True
+
+            resp = requests.post(url, timeout=10, json={"data": data})
+            resp.raise_for_status()
+
+        except requests.HTTPError:
+            return Response(
+                {"success": False, "payload": resp.text},
+                status=status.HTTP_502_BAD_GATEWAY,
+                content_type="application/json",
+            )
+
+        return Response(
+            {"success": True, "payload": resp.json()},
+            status=status.HTTP_200_OK,
+            content_type="application/json",
+        )
+
 
 class VolumeViewSet(NetBoxModelViewSet):
     """Volume view set class"""
diff --git a/netbox_docker_plugin/templates/netbox_docker_plugin/image.html b/netbox_docker_plugin/templates/netbox_docker_plugin/image.html
index 91fadf8..f7dd690 100644
--- a/netbox_docker_plugin/templates/netbox_docker_plugin/image.html
+++ b/netbox_docker_plugin/templates/netbox_docker_plugin/image.html
@@ -2,6 +2,69 @@
 
 {% load plugins %}
 
+{% block extra_controls %}
+<button
+  class="btn btn-sm btn-success"
+  onclick="
+    (async () => {
+      this.setAttribute('disabled', 'disabled')
+      const notificationData = {}
+      try {
+        const resp = await fetch(
+          '/api/plugins/docker/images/{{ object.pk }}/force_pull/',
+          {
+            method: 'POST',
+            headers: {
+              'X-CSRFToken': window.CSRF_TOKEN,
+            },
+          },
+        )
+        const data = await resp.json()
+        console.log(data)
+        notificationData.kind = 'success'
+        notificationData.icon = ['mdi', 'mdi-check']
+        notificationData.message = 'Pulling image again'
+      }
+      catch (err) {
+        console.error(err)
+        notificationData.kind = 'danger'
+        notificationData.icon = ['mdi', 'mdi-alert']
+        notificationData.message = 'Failed to force pull image'
+      }
+      finally {
+        this.removeAttribute('disabled')
+      }
+      const notification = document.createElement('div')
+      notification.classList.add('toast', 'toast-dark', 'border-0', 'shadow-sm', 'rounded-0')
+      notification.setAttribute('role', 'alert')
+      notification.setAttribute('aria-live', 'assertive')
+      notification.setAttribute('aria-atomic', 'true')
+      notification.dataset['bsDelay'] = 10_000
+      const header = document.createElement('div')
+      header.classList.add('toast-header', `bg-${notificationData.kind}`, 'rounded-0')
+      const headerIcon = document.createElement('i')
+      headerIcon.classList.add(...notificationData.icon, 'me-2')
+      const headerTitle = document.createElement('span')
+      headerTitle.innerText = notificationData.message
+      const closeButton = document.createElement('button')
+      closeButton.classList.add('btn-close', 'me-0', 'm-auto')
+      closeButton.setAttribute('type', 'button')
+      closeButton.setAttribute('aria-label', 'Close')
+      closeButton.dataset['bsDismiss'] = 'toast'
+      header.appendChild(headerIcon)
+      header.appendChild(headerTitle)
+      header.appendChild(closeButton)
+      notification.appendChild(header)
+      document.querySelector('.toast-container').appendChild(notification)
+      const toast = new Toast(notification)
+      toast.show()
+    })()
+  "
+>
+  <i class="mdi mdi-reload"></i> Force Pull
+</button>
+{% endblock %}
+
 {% block content %}
 <div class="row mb-3">
   <div class="col col-md-6">
diff --git a/pyproject.toml b/pyproject.toml
index d8a51bd..553d7c4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "netbox-docker-plugin"
-version = "2.2.0"
+version = "2.3.0"
 authors = [
   { name="Vincent Simonin", email="vincent@saashup.com" },
   { name="David Delassus", email="david.jose.delassus@gmail.com" }


Test patch:
diff --git a/netbox_docker_plugin/tests/image/test_image_api.py b/netbox_docker_plugin/tests/image/test_image_api.py
index df5f1de..83ad140 100644
--- a/netbox_docker_plugin/tests/image/test_image_api.py
+++ b/netbox_docker_plugin/tests/image/test_image_api.py
@@ -1,5 +1,10 @@
 """Image Test Case"""
 
+import requests_mock
+from django.urls import reverse
+from core.models import ObjectType
+from rest_framework import status
+from users.models import ObjectPermission
 from utilities.testing import APIViewTestCases
 from netbox_docker_plugin.models.host import Host
 from netbox_docker_plugin.models.image import Image
@@ -66,3 +71,35 @@ def setUpTestData(cls) -> None:
                 "registry": registry.pk,
             },
         ]
+
+    def test_force_pull_endpoint(self):
+        """Test force pull endpoint"""
+
+        image = Image.objects.get(name="image1")
+
+        endpoint = reverse(
+            viewname=f"plugins-api:{self._get_view_namespace()}:image-force-pull",
+            kwargs={"pk": image.pk},
+        )
+
+        # Add object-level permission
+        obj_perm = ObjectPermission(
+            name="Test permission",
+            constraints={"pk": image.pk},
+            actions=["add"],
+        )
+        obj_perm.save()
+        # pylint: disable=E1101
+        obj_perm.users.add(self.user)
+        # pylint: disable=E1101
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+        with requests_mock.Mocker() as m:
+            m.post(
+                "http://localhost:8080/api/engine/images",
+                text="{}",
+            )
+
+            response = self.client.post(endpoint, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            self.assertEqual(response.data, {"success": True, "payload": {}})