From 0e0ba66b9a2c87a85c889872c1ddda860840fbdd Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 18 May 2022 21:40:53 +1000
Subject: [PATCH 1/3] Fix broken calls to offload_task

---
 InvenTree/plugin/base/integration/mixins.py | 3 ++-
 InvenTree/plugin/base/locate/api.py         | 6 +++---
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py
index 86e3092e4f..64de5df22b 100644
--- a/InvenTree/plugin/base/integration/mixins.py
+++ b/InvenTree/plugin/base/integration/mixins.py
@@ -13,6 +13,7 @@ import InvenTree.helpers
 
 from plugin.helpers import MixinImplementationError, MixinNotImplementedError, render_template
 from plugin.models import PluginConfig, PluginSetting
+from plugin.registry import registry
 from plugin.urls import PLUGIN_BASE
 
 
@@ -204,7 +205,7 @@ class ScheduleMixin:
 
                     Schedule.objects.create(
                         name=task_name,
-                        func='plugin.registry.call_function',
+                        func=registry.call_plugin_function,
                         args=f"'{slug}', '{func_name}'",
                         schedule_type=task['schedule'],
                         minutes=task.get('minutes', None),
diff --git a/InvenTree/plugin/base/locate/api.py b/InvenTree/plugin/base/locate/api.py
index a6776f2d40..f617ba3577 100644
--- a/InvenTree/plugin/base/locate/api.py
+++ b/InvenTree/plugin/base/locate/api.py
@@ -7,7 +7,7 @@ from rest_framework.views import APIView
 
 from InvenTree.tasks import offload_task
 
-from plugin import registry
+from plugin.registry import registry
 from stock.models import StockItem, StockLocation
 
 
@@ -53,7 +53,7 @@ class LocatePluginView(APIView):
             try:
                 StockItem.objects.get(pk=item_pk)
 
-                offload_task(registry.call_function, plugin, 'locate_stock_item', item_pk)
+                offload_task(registry.call_plugin_function, plugin, 'locate_stock_item', item_pk)
 
                 data['item'] = item_pk
 
@@ -66,7 +66,7 @@ class LocatePluginView(APIView):
             try:
                 StockLocation.objects.get(pk=location_pk)
 
-                offload_task(registry.call_function, plugin, 'locate_stock_location', location_pk)
+                offload_task(registry.call_plugin_function, plugin, 'locate_stock_location', location_pk)
 
                 data['location'] = location_pk
 

From dd476ce796103f2a8fe84a0be240604249a060a8 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 18 May 2022 22:20:29 +1000
Subject: [PATCH 2/3] Add unit tests for the 'locate' plugin

- Test various failure modes
- Some of the failure modes didn't fail - this is also a failure
- Fixing API code accordingly
---
 InvenTree/plugin/base/locate/api.py         | 14 ++--
 InvenTree/plugin/base/locate/test_locate.py | 89 +++++++++++++++++++++
 2 files changed, 95 insertions(+), 8 deletions(-)
 create mode 100644 InvenTree/plugin/base/locate/test_locate.py

diff --git a/InvenTree/plugin/base/locate/api.py b/InvenTree/plugin/base/locate/api.py
index f617ba3577..a7effe91b3 100644
--- a/InvenTree/plugin/base/locate/api.py
+++ b/InvenTree/plugin/base/locate/api.py
@@ -40,9 +40,6 @@ class LocatePluginView(APIView):
         # StockLocation to identify
         location_pk = request.data.get('location', None)
 
-        if not item_pk and not location_pk:
-            raise ParseError("Must supply either 'item' or 'location' parameter")
-
         data = {
             "success": "Identification plugin activated",
             "plugin": plugin,
@@ -59,8 +56,8 @@ class LocatePluginView(APIView):
 
                 return Response(data)
 
-            except StockItem.DoesNotExist:
-                raise NotFound("StockItem matching PK '{item}' not found")
+            except (ValueError, StockItem.DoesNotExist):
+                raise NotFound(f"StockItem matching PK '{item_pk}' not found")
 
         elif location_pk:
             try:
@@ -72,8 +69,9 @@ class LocatePluginView(APIView):
 
                 return Response(data)
 
-            except StockLocation.DoesNotExist:
-                raise NotFound("StockLocation matching PK {'location'} not found")
+            except (ValueError, StockLocation.DoesNotExist):
+                raise NotFound(f"StockLocation matching PK '{location_pk}' not found")
 
         else:
-            raise NotFound()
+            raise ParseError("Must supply either 'item' or 'location' parameter")
+
diff --git a/InvenTree/plugin/base/locate/test_locate.py b/InvenTree/plugin/base/locate/test_locate.py
new file mode 100644
index 0000000000..26fafcca49
--- /dev/null
+++ b/InvenTree/plugin/base/locate/test_locate.py
@@ -0,0 +1,89 @@
+"""
+Unit tests for the 'locate' plugin mixin class
+"""
+
+from django.urls import reverse
+
+from InvenTree.api_tester import InvenTreeAPITestCase
+
+from plugin.registry import registry
+
+
+class LocatePluginTests(InvenTreeAPITestCase):
+
+    fixtures = [
+        'category',
+        'part',
+        'location',
+        'stock',
+    ]
+
+    def test_installed(self):
+        """Test that a locate plugin is actually installed"""
+
+        plugins = registry.with_mixin('locate')
+
+        self.assertTrue(len(plugins) > 0)
+
+        self.assertTrue('samplelocate' in [p.slug for p in plugins])
+
+    def test_locate_fail(self):
+        """Test various API failure modes"""
+        
+        url = reverse('api-locate-plugin')
+
+        # Post without a plugin
+        response = self.post(
+            url,
+            {},
+            expected_code=400
+        )
+
+        self.assertIn("'plugin' field must be supplied", str(response.data))
+
+        # Post with a plugin that does not exist, or is invalid
+        for slug in ['xyz', 'event', 'plugin']:
+            response = self.post(
+                url,
+                {
+                    'plugin': slug,
+                },
+                expected_code=400,
+            )
+
+            self.assertIn(f"Plugin '{slug}' is not installed, or does not support the location mixin", str(response.data))
+
+        # Post with a valid plugin, but no other data
+        response = self.post(
+            url,
+            {
+                'plugin': 'samplelocate',
+            },
+            expected_code=400
+        )
+
+        self.assertIn("Must supply either 'item' or 'location' parameter", str(response.data))
+
+        # Post with valid plugin, invalid item or location
+        for pk in ['qq', 99999, -42]:
+            response = self.post(
+                url,
+                {
+                    'plugin': 'samplelocate',
+                    'item': pk,
+                },
+                expected_code=404
+            )
+
+            self.assertIn(f"StockItem matching PK '{pk}' not found", str(response.data))
+
+            response = self.post(
+                url,
+                {
+                    'plugin': 'samplelocate',
+                    'location': pk,
+                },
+                expected_code=404,
+            )
+
+            self.assertIn(f"StockLocation matching PK '{pk}' not found", str(response.data))
\ No newline at end of file

From c6590066b865416e5761718e2a61dca06ad44e81 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 18 May 2022 22:46:15 +1000
Subject: [PATCH 3/3] Add tests for successful location

- Sample plugin now updates metadata tag
---
 InvenTree/plugin/base/locate/api.py           |  1 -
 InvenTree/plugin/base/locate/test_locate.py   | 63 ++++++++++++++++++-
 .../plugin/samples/locate/locate_sample.py    | 24 ++++++-
 3 files changed, 83 insertions(+), 5 deletions(-)

diff --git a/InvenTree/plugin/base/locate/api.py b/InvenTree/plugin/base/locate/api.py
index a7effe91b3..3004abb262 100644
--- a/InvenTree/plugin/base/locate/api.py
+++ b/InvenTree/plugin/base/locate/api.py
@@ -74,4 +74,3 @@ class LocatePluginView(APIView):
 
         else:
             raise ParseError("Must supply either 'item' or 'location' parameter")
-
diff --git a/InvenTree/plugin/base/locate/test_locate.py b/InvenTree/plugin/base/locate/test_locate.py
index 26fafcca49..e145c2360b 100644
--- a/InvenTree/plugin/base/locate/test_locate.py
+++ b/InvenTree/plugin/base/locate/test_locate.py
@@ -7,6 +7,7 @@ from django.urls import reverse
 from InvenTree.api_tester import InvenTreeAPITestCase
 
 from plugin.registry import registry
+from stock.models import StockItem, StockLocation
 
 
 class LocatePluginTests(InvenTreeAPITestCase):
@@ -29,7 +30,7 @@ class LocatePluginTests(InvenTreeAPITestCase):
 
     def test_locate_fail(self):
         """Test various API failure modes"""
-        
+
         url = reverse('api-locate-plugin')
 
         # Post without a plugin
@@ -86,4 +87,62 @@ class LocatePluginTests(InvenTreeAPITestCase):
                 expected_code=404,
             )
 
-            self.assertIn(f"StockLocation matching PK '{pk}' not found", str(response.data))
\ No newline at end of file
+            self.assertIn(f"StockLocation matching PK '{pk}' not found", str(response.data))
+
+    def test_locate_item(self):
+        """
+        Test that the plugin correctly 'locates' a StockItem
+
+        As the background worker is not running during unit testing,
+        the sample 'locate' function will be called 'inline'
+        """
+
+        url = reverse('api-locate-plugin')
+
+        item = StockItem.objects.get(pk=1)
+
+        # The sample plugin will set the 'located' metadata tag
+        item.set_metadata('located', False)
+
+        response = self.post(
+            url,
+            {
+                'plugin': 'samplelocate',
+                'item': 1,
+            },
+            expected_code=200
+        )
+
+        self.assertEqual(response.data['item'], 1)
+
+        item.refresh_from_db()
+
+        # Item metadata should have been altered!
+        self.assertTrue(item.metadata['located'])
+
+    def test_locate_location(self):
+        """
+        Test that the plugin correctly 'locates' a StockLocation
+        """
+
+        url = reverse('api-locate-plugin')
+
+        for location in StockLocation.objects.all():
+
+            location.set_metadata('located', False)
+
+            response = self.post(
+                url,
+                {
+                    'plugin': 'samplelocate',
+                    'location': location.pk,
+                },
+                expected_code=200
+            )
+
+            self.assertEqual(response.data['location'], location.pk)
+
+            location.refresh_from_db()
+
+            # Item metadata should have been altered!
+            self.assertTrue(location.metadata['located'])
diff --git a/InvenTree/plugin/samples/locate/locate_sample.py b/InvenTree/plugin/samples/locate/locate_sample.py
index 458b84cfa5..32a2dd713c 100644
--- a/InvenTree/plugin/samples/locate/locate_sample.py
+++ b/InvenTree/plugin/samples/locate/locate_sample.py
@@ -23,7 +23,23 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
     SLUG = "samplelocate"
     TITLE = "Sample plugin for locating items"
 
-    VERSION = "0.1"
+    VERSION = "0.2"
+
+    def locate_stock_item(self, item_pk):
+
+        from stock.models import StockItem
+
+        logger.info(f"SampleLocatePlugin attempting to locate item ID {item_pk}")
+
+        try:
+            item = StockItem.objects.get(pk=item_pk)
+            logger.info(f"StockItem {item_pk} located!")
+
+            # Tag metadata
+            item.set_metadata('located', True)
+
+        except (ValueError, StockItem.DoesNotExist):
+            logger.error(f"StockItem ID {item_pk} does not exist!")
 
     def locate_stock_location(self, location_pk):
 
@@ -34,5 +50,9 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
         try:
             location = StockLocation.objects.get(pk=location_pk)
             logger.info(f"Location exists at '{location.pathstring}'")
-        except StockLocation.DoesNotExist:
+
+            # Tag metadata
+            location.set_metadata('located', True)
+
+        except (ValueError, StockLocation.DoesNotExist):
             logger.error(f"Location ID {location_pk} does not exist!")