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