From da29478a1cbd03f86f86c3fac87be8014514bbb6 Mon Sep 17 00:00:00 2001
From: Bobbe <34186858+30350n@users.noreply.github.com>
Date: Mon, 11 Dec 2023 12:46:34 +0100
Subject: [PATCH] Add add_link function to Attachment class (#214)

* Add add_link function to Attachment class

* Fix invoke test command in TESTING.md

* Add generic AttachmentMixin class generator

* Fix ManufacturerPart attachments

* Fix multiple inheritance formatting

* Bump version to 0.13.2
---
 TESTING.md                  |  2 +-
 inventree/base.py           | 82 +++++++++++++++++++++++++++++++++----
 inventree/build.py          | 28 ++++---------
 inventree/company.py        | 35 ++++++----------
 inventree/part.py           | 43 +++++++------------
 inventree/purchase_order.py | 28 ++++---------
 inventree/return_order.py   | 32 +++++----------
 inventree/sales_order.py    | 29 ++++---------
 inventree/stock.py          | 48 ++++++++--------------
 test/test_part.py           | 23 +++++++++++
 10 files changed, 181 insertions(+), 169 deletions(-)

diff --git a/TESTING.md b/TESTING.md
index fe83babd..6a9722b0 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -30,7 +30,7 @@ Before the first test, run the following:
 invoke update-image
 ```
 
-The `invoke-test` command performs the following sequence of actions:
+The `invoke test` command performs the following sequence of actions:
 
 - Ensures the test InvenTree server is running (in a docker container)
 - Resets the test database to a known state
diff --git a/inventree/base.py b/inventree/base.py
index 217b8fc1..03b038e1 100644
--- a/inventree/base.py
+++ b/inventree/base.py
@@ -3,10 +3,11 @@
 import json
 import logging
 import os
+from typing import Type
 
 from . import api as inventree_api
 
-INVENTREE_PYTHON_VERSION = "0.13.1"
+INVENTREE_PYTHON_VERSION = "0.13.2"
 
 
 logger = logging.getLogger('inventree')
@@ -375,8 +376,34 @@ class Attachment(BulkDeleteMixin, InventreeObject):
     Multiple sub-classes exist, representing various types of attachment models in the database.
     """
 
-    # List of required kwargs required for the particular subclass
-    REQUIRED_KWARGS = []
+    # Name of the primary key field of the InventreeObject the attachment will be attached to
+    ATTACH_TO = None
+
+    @classmethod
+    def add_link(cls, api, link, comment="", **kwargs):
+        """
+        Add an external link attachment.
+
+        Args:
+            api: Authenticated InvenTree API instance
+            link: External link to attach
+            comment: Add comment to the attachment
+            kwargs: Additional kwargs to suppl
+        """
+
+        data = kwargs
+        data["comment"] = comment
+        data["link"] = link
+
+        if cls.ATTACH_TO not in kwargs:
+            raise ValueError(f"Required argument '{cls.ATTACH_TO}' not supplied to add_link method")
+
+        if response := api.post(cls.URL, data):
+            logger.info(f"Link attachment added to {cls.URL}")
+        else:
+            logger.error(f"Link attachment failed at {cls.URL}")
+
+        return response
 
     @classmethod
     def upload(cls, api, attachment, comment='', **kwargs):
@@ -394,10 +421,8 @@ def upload(cls, api, attachment, comment='', **kwargs):
         data = kwargs
         data['comment'] = comment
 
-        # Check that the extra kwargs are provided
-        for arg in cls.REQUIRED_KWARGS:
-            if arg not in kwargs:
-                raise ValueError(f"Required argument '{arg}' not supplied to upload method")
+        if cls.ATTACH_TO not in kwargs:
+            raise ValueError(f"Required argument '{cls.ATTACH_TO}' not supplied to upload method")
 
         if type(attachment) is str:
             if not os.path.exists(attachment):
@@ -440,6 +465,49 @@ def download(self, destination, **kwargs):
         return self._api.downloadFile(self.attachment, destination, **kwargs)
 
 
+def AttachmentMixin(AttachmentSubClass: Type[Attachment]):
+    class Mixin(Attachment):
+        def getAttachments(self):
+            return AttachmentSubClass.list(
+                self._api,
+                **{AttachmentSubClass.ATTACH_TO: self.pk},
+            )
+
+        def uploadAttachment(self, attachment, comment=""):
+            """
+            Upload an attachment (file) against this Object.
+
+            Args:
+                attachment: Either a string (filename) or a file object
+                comment: Attachment comment
+            """
+
+            return AttachmentSubClass.upload(
+                self._api,
+                attachment,
+                comment=comment,
+                **{AttachmentSubClass.ATTACH_TO: self.pk},
+            )
+        
+        def addLinkAttachment(self, link, comment=""):
+            """
+            Add an external link attachment against this Object.
+
+            Args:
+                link: The link to attach
+                comment: Attachment comment
+            """
+
+            return AttachmentSubClass.add_link(
+                self._api,
+                link,
+                comment=comment,
+                **{AttachmentSubClass.ATTACH_TO: self.pk},
+            )
+
+    return Mixin
+
+
 class MetadataMixin:
     """Mixin class for models which support a 'metadata' attribute.
 
diff --git a/inventree/build.py b/inventree/build.py
index 3a42d336..d40dcee3 100644
--- a/inventree/build.py
+++ b/inventree/build.py
@@ -4,11 +4,19 @@
 import inventree.report
 
 
+class BuildAttachment(inventree.base.Attachment):
+    """Class representing an attachment against a Build object"""
+
+    URL = 'build/attachment'
+    ATTACH_TO = 'build'
+
+
 class Build(
-    inventree.base.InventreeObject,
+    inventree.base.AttachmentMixin(BuildAttachment),
     inventree.base.StatusMixin,
     inventree.base.MetadataMixin,
     inventree.report.ReportPrintingMixin,
+    inventree.base.InventreeObject,
 ):
     """ Class representing the Build database model """
 
@@ -18,17 +26,6 @@ class Build(
     REPORTNAME = 'build'
     REPORTITEM = 'build'
 
-    def getAttachments(self):
-        return BuildAttachment.list(self._api, build=self.pk)
-
-    def uploadAttachment(self, attachment, comment=''):
-        return BuildAttachment.upload(
-            self._api,
-            attachment,
-            comment=comment,
-            build=self.pk
-        )
-
     def complete(
         self,
         accept_overallocated='reject',
@@ -52,10 +49,3 @@ def complete(
     def finish(self, *args, **kwargs):
         """Alias for complete"""
         return self.complete(*args, **kwargs)
-
-
-class BuildAttachment(inventree.base.Attachment):
-    """Class representing an attachment against a Build object"""
-
-    URL = 'build/attachment'
-    REQUIRED_KWARGS = ['build']
diff --git a/inventree/company.py b/inventree/company.py
index 22ecd8e7..a158aed5 100644
--- a/inventree/company.py
+++ b/inventree/company.py
@@ -98,7 +98,19 @@ def getPriceBreaks(self):
         return SupplierPriceBreak.list(self._api, part=self.pk)
 
 
-class ManufacturerPart(inventree.base.BulkDeleteMixin, inventree.base.MetadataMixin, inventree.base.InventreeObject):
+class ManufacturerPartAttachment(inventree.base.Attachment):
+    """Class representing an attachment against a ManufacturerPart object"""
+
+    URL = 'company/part/manufacturer/attachment'
+    ATTACH_TO = 'manufacturer_part'
+
+
+class ManufacturerPart(
+    inventree.base.AttachmentMixin(ManufacturerPartAttachment),
+    inventree.base.BulkDeleteMixin,
+    inventree.base.MetadataMixin,
+    inventree.base.InventreeObject,
+):
     """Class representing the ManufacturerPart database model
 
     - Implements the BulkDeleteMixin
@@ -113,19 +125,6 @@ def getParameters(self, **kwargs):
 
         return ManufacturerPartParameter.list(self._api, manufacturer_part=self.pk, **kwargs)
 
-    def getAttachments(self, **kwargs):
-
-        return ManufacturerPartAttachment.list(self._api, manufacturer_part=self.pk, **kwargs)
-
-    def uploadAttachment(self, attachment, comment=''):
-
-        return ManufacturerPartAttachment.upload(
-            self._api,
-            attachment,
-            comment=comment,
-            manufacturer_part=self.pk,
-        )
-
 
 class ManufacturerPartParameter(inventree.base.BulkDeleteMixin, inventree.base.InventreeObject):
     """Class representing the ManufacturerPartParameter database model.
@@ -136,14 +135,6 @@ class ManufacturerPartParameter(inventree.base.BulkDeleteMixin, inventree.base.I
     URL = 'company/part/manufacturer/parameter'
 
 
-class ManufacturerPartAttachment(inventree.base.Attachment):
-    """
-    Class representing the ManufacturerPartAttachment model
-    """
-
-    URL = 'company/part/manufacturer/attachment'
-
-
 class SupplierPriceBreak(inventree.base.InventreeObject):
     """ Class representing the SupplierPriceBreak database model """
 
diff --git a/inventree/part.py b/inventree/part.py
index 07deb1d7..dcf8b95c 100644
--- a/inventree/part.py
+++ b/inventree/part.py
@@ -58,7 +58,21 @@ def getCategoryParameterTemplates(self, fetch_parent: bool = True) -> list:
         )
 
 
-class Part(inventree.base.BarcodeMixin, inventree.base.MetadataMixin, inventree.base.ImageMixin, inventree.label.LabelPrintingMixin, inventree.base.InventreeObject):
+class PartAttachment(inventree.base.Attachment):
+    """Class representing a file attachment for a Part"""
+
+    URL = 'part/attachment'
+    ATTACH_TO = 'part'
+
+
+class Part(
+    inventree.base.AttachmentMixin(PartAttachment),
+    inventree.base.BarcodeMixin,
+    inventree.base.MetadataMixin,
+    inventree.base.ImageMixin,
+    inventree.label.LabelPrintingMixin,
+    inventree.base.InventreeObject,
+):
     """ Class representing the Part database model """
 
     URL = 'part'
@@ -124,25 +138,6 @@ def setInternalPrice(self, quantity: int, price: float):
 
         return InternalPrice.setInternalPrice(self._api, self.pk, quantity, price)
 
-    def getAttachments(self):
-        return PartAttachment.list(self._api, part=self.pk)
-
-    def uploadAttachment(self, attachment, comment=''):
-        """
-        Upload an attachment (file) against this Part.
-
-        Args:
-            attachment: Either a string (filename) or a file object
-            comment: Attachment comment
-        """
-
-        return PartAttachment.upload(
-            self._api,
-            attachment,
-            comment=comment,
-            part=self.pk
-        )
-
     def getRequirements(self):
         """
         Get required amounts from requirements API endpoint for this part
@@ -155,14 +150,6 @@ def getRequirements(self):
         return self._api.get(URL)
 
 
-class PartAttachment(inventree.base.Attachment):
-    """ Class representing a file attachment for a Part """
-
-    URL = 'part/attachment'
-
-    REQUIRED_KWARGS = ['part']
-
-
 class PartTestTemplate(inventree.base.MetadataMixin, inventree.base.InventreeObject):
     """ Class representing a test template for a Part """
 
diff --git a/inventree/purchase_order.py b/inventree/purchase_order.py
index 5f109019..c3c7873b 100644
--- a/inventree/purchase_order.py
+++ b/inventree/purchase_order.py
@@ -8,11 +8,19 @@
 import inventree.report
 
 
+class PurchaseOrderAttachment(inventree.base.Attachment):
+    """Class representing a file attachment for a PurchaseOrder"""
+
+    URL = 'order/po/attachment'
+    ATTACH_TO = 'order'
+
+
 class PurchaseOrder(
+    inventree.base.AttachmentMixin(PurchaseOrderAttachment),
     inventree.base.MetadataMixin,
-    inventree.base.InventreeObject,
     inventree.base.StatusMixin,
     inventree.report.ReportPrintingMixin,
+    inventree.base.InventreeObject,
 ):
     """ Class representing the PurchaseOrder database model """
 
@@ -59,17 +67,6 @@ def addExtraLineItem(self, **kwargs):
 
         return PurchaseOrderExtraLineItem.create(self._api, data=kwargs)
 
-    def getAttachments(self):
-        return PurchaseOrderAttachment.list(self._api, order=self.pk)
-
-    def uploadAttachment(self, attachment, comment=''):
-        return PurchaseOrderAttachment.upload(
-            self._api,
-            attachment,
-            comment=comment,
-            order=self.pk,
-        )
-
     def issue(self, **kwargs):
         """
         Issue the purchase order
@@ -248,10 +245,3 @@ def getOrder(self):
         Return the PurchaseOrder to which this PurchaseOrderLineItem belongs
         """
         return PurchaseOrder(self._api, self.order)
-
-
-class PurchaseOrderAttachment(inventree.base.Attachment):
-    """Class representing a file attachment for a PurchaseOrder"""
-
-    URL = 'order/po/attachment'
-    REQUIRED_KWARGS = ['order']
diff --git a/inventree/return_order.py b/inventree/return_order.py
index f0ad4b2f..d1886fe2 100644
--- a/inventree/return_order.py
+++ b/inventree/return_order.py
@@ -9,11 +9,20 @@
 import inventree.stock
 
 
+class ReturnOrderAttachment(inventree.base.InventreeObject):
+    """Class representing the ReturnOrderAttachment model"""
+
+    URL = 'order/ro/attachment'
+    ATTACH_TO = 'order'
+    REQUIRED_API_VERSION = 104
+
+
 class ReturnOrder(
+    inventree.base.AttachmentMixin(ReturnOrderAttachment),
     inventree.base.MetadataMixin,
-    inventree.base.InventreeObject,
     inventree.base.StatusMixin,
     inventree.report.ReportPrintingMixin,
+    inventree.base.InventreeObject,
 ):
     """Class representing the ReturnOrder database model"""
 
@@ -53,19 +62,6 @@ def addExtraLineItem(self, **kwargs):
         kwargs['order'] = self.pk
         return ReturnOrderExtraLineItem.create(self._api, data=kwargs)
 
-    def getAttachments(self):
-        """Return a list of attachments associated with this order"""
-        return ReturnOrderAttachment.list(self._api, order=self.pk)
-
-    def uploadAttachment(self, attachment, comment=''):
-        """Upload a file attachment against this order"""
-        return ReturnOrderAttachment.upload(
-            self._api,
-            attachment,
-            comment=comment,
-            order=self.pk
-        )
-
     def issue(self, **kwargs):
         """Issue (send) this order"""
         return self._statusupdate(status='issue', **kwargs)
@@ -103,11 +99,3 @@ class ReturnOrderExtraLineItem(inventree.base.InventreeObject):
     def getOrder(self):
         """Return the ReturnOrder to which this line item belongs"""
         return ReturnOrder(self._api, self.order)
-
-
-class ReturnOrderAttachment(inventree.base.InventreeObject):
-    """Class representing the ReturnOrderAttachment model"""
-
-    URL = 'order/ro/attachment'
-    REQUIRED_KWARGS = ['order']
-    REQUIRED_API_VERSION = 104
diff --git a/inventree/sales_order.py b/inventree/sales_order.py
index 4add09be..4e7ed1b9 100644
--- a/inventree/sales_order.py
+++ b/inventree/sales_order.py
@@ -8,11 +8,19 @@
 import inventree.report
 
 
+class SalesOrderAttachment(inventree.base.Attachment):
+    """Class representing a file attachment for a SalesOrder"""
+
+    URL = 'order/so/attachment'
+    ATTACH_TO = 'order'
+
+
 class SalesOrder(
+    inventree.base.AttachmentMixin(SalesOrderAttachment),
     inventree.base.MetadataMixin,
-    inventree.base.InventreeObject,
     inventree.base.StatusMixin,
     inventree.report.ReportPrintingMixin,
+    inventree.base.InventreeObject,
 ):
     """ Class representing the SalesOrder database model """
 
@@ -59,17 +67,6 @@ def addExtraLineItem(self, **kwargs):
 
         return SalesOrderExtraLineItem.create(self._api, data=kwargs)
 
-    def getAttachments(self):
-        return SalesOrderAttachment.list(self._api, order=self.pk)
-
-    def uploadAttachment(self, attachment, comment=''):
-        return SalesOrderAttachment.upload(
-            self._api,
-            attachment,
-            comment=comment,
-            order=self.pk,
-        )
-
     def getShipments(self, **kwargs):
         """ Return the shipments associated with this order """
 
@@ -191,14 +188,6 @@ def getOrder(self):
         return SalesOrder(self._api, self.order)
 
 
-class SalesOrderAttachment(inventree.base.Attachment):
-    """Class representing a file attachment for a SalesOrder"""
-
-    URL = 'order/so/attachment'
-
-    REQUIRED_KWARGS = ['order']
-
-
 class SalesOrderShipment(
     inventree.base.InventreeObject,
     inventree.base.StatusMixin,
diff --git a/inventree/stock.py b/inventree/stock.py
index 2eb7c91e..e8b0d49e 100644
--- a/inventree/stock.py
+++ b/inventree/stock.py
@@ -16,7 +16,7 @@ class StockLocation(
     inventree.base.MetadataMixin,
     inventree.label.LabelPrintingMixin,
     inventree.report.ReportPrintingMixin,
-    inventree.base.InventreeObject
+    inventree.base.InventreeObject,
 ):
     """ Class representing the StockLocation database model """
 
@@ -51,7 +51,21 @@ def getChildLocations(self, **kwargs):
         return StockLocation.list(self._api, parent=self.pk, **kwargs)
 
 
-class StockItem(inventree.base.BarcodeMixin, inventree.base.BulkDeleteMixin, inventree.base.MetadataMixin, inventree.label.LabelPrintingMixin, inventree.base.InventreeObject):
+class StockItemAttachment(inventree.base.Attachment):
+    """Class representing a file attachment for a StockItem"""
+
+    URL = 'stock/attachment'
+    ATTACH_TO = 'stock_item'
+
+
+class StockItem(
+    inventree.base.AttachmentMixin(StockItemAttachment),
+    inventree.base.BarcodeMixin,
+    inventree.base.BulkDeleteMixin,
+    inventree.base.MetadataMixin,
+    inventree.label.LabelPrintingMixin,
+    inventree.base.InventreeObject,
+):
     """Class representing the StockItem database model."""
 
     URL = 'stock'
@@ -313,34 +327,6 @@ def uploadTestResult(self, test_name, test_result, **kwargs):
 
         return StockItemTestResult.upload_result(self._api, self.pk, test_name, test_result, **kwargs)
 
-    def getAttachments(self):
-        """ Return all file attachments for this StockItem """
-
-        return StockItemAttachment.list(
-            self._api,
-            stock_item=self.pk
-        )
-
-    def uploadAttachment(self, attachment, comment=''):
-        """
-        Upload an attachment against this StockItem
-        """
-
-        return StockItemAttachment.upload(
-            self._api,
-            attachment,
-            comment=comment,
-            stock_item=self.pk
-        )
-
-
-class StockItemAttachment(inventree.base.Attachment):
-    """ Class representing a file attachment for a StockItem """
-
-    URL = 'stock/attachment'
-
-    REQUIRED_KWARGS = ['stock_item']
-
 
 class StockItemTracking(inventree.base.InventreeObject):
     """ Class representing a StockItem tracking object """
@@ -352,7 +338,7 @@ class StockItemTestResult(
     inventree.base.BulkDeleteMixin,
     inventree.base.MetadataMixin,
     inventree.report.ReportPrintingMixin,
-    inventree.base.InventreeObject
+    inventree.base.InventreeObject,
 ):
     """Class representing a StockItemTestResult object"""
 
diff --git a/test/test_part.py b/test/test_part.py
index 39eb89da..0fd8ec7f 100644
--- a/test/test_part.py
+++ b/test/test_part.py
@@ -502,6 +502,29 @@ def test_part_attachment(self):
             # Attempt to download the file again, but without overwrite option
             attachment.download(dst)
 
+    def test_part_link_attachment(self):
+        """
+        Check that we can add an external link attachment to the part
+        """
+
+        test_link = "https://inventree.org/"
+        test_comment = "inventree.org"
+
+        # Test that an external link attachment without the required 'part' parameter fails
+        with self.assertRaises(ValueError):
+            PartAttachment.add_link(self.api, link=test_link)
+
+        # Add valid external link attachment
+        part = Part(self.api, pk=1)
+        response = part.addLinkAttachment(test_link, comment=test_comment)
+        self.assertIsNotNone(response)
+
+        # Check that the attachment has been created
+        attachment = PartAttachment(self.api, pk=response["pk"])
+        self.assertTrue(attachment.is_valid())
+        self.assertEqual(attachment.link, test_link)
+        self.assertEqual(attachment.comment, test_comment)
+
     def test_set_price(self):
         """
         Tests that an internal price can be set for a part