From c18cc321d0c4deaf57699149f3f84c450e5bd84e Mon Sep 17 00:00:00 2001 From: miggland Date: Fri, 12 Jan 2024 15:32:33 +0100 Subject: [PATCH] Add functions to easily download template files, create new labels/reports, and update settings (#212) * Add functions to easily download template files, create new labels/reports, and update settings * Handle closing of files after upload * Clarify if statement * Add tests for creating and downloading report/label templates * Extend test to use save method, other methods of specifying template file. Fix template file location * Correct the order of arguments in isinstance * Compare contents of file, not names.. * Handle open files better * Put file comparison in function for easier maintenance * Make sure to close files also in create method * Rename variable for clarity * Change name of argument * Skip tests for ReportBuild method (due to https://github.com/inventree/InvenTree/issues/6213) * That didn't work as expected... * Replace with assertIsInstance method * Again, not working --- inventree/api.py | 8 +-- inventree/label.py | 80 +++++++++++++++++++++++++- inventree/report.py | 91 +++++++++++++++++++++++++++--- test/dummytemplate.html | 5 ++ test/dummytemplate2.html | 5 ++ test/test_label.py | 99 ++++++++++++++++++++++++++++++++ test/test_report.py | 118 +++++++++++++++++++++++++++++++++++++-- 7 files changed, 388 insertions(+), 18 deletions(-) create mode 100644 test/dummytemplate.html create mode 100644 test/dummytemplate2.html diff --git a/inventree/api.py b/inventree/api.py index a6902a6..0e0284d 100644 --- a/inventree/api.py +++ b/inventree/api.py @@ -547,13 +547,13 @@ def downloadFile(self, url, destination, overwrite=False, params=None, proxies=d if url.startswith('/'): url = url[1:] - url = urljoin(self.base_url, url) + fullurl = urljoin(self.base_url, url) if os.path.exists(destination) and os.path.isdir(destination): destination = os.path.join( destination, - os.path.basename(url) + os.path.basename(fullurl) ) destination = os.path.abspath(destination) @@ -571,7 +571,7 @@ def downloadFile(self, url, destination, overwrite=False, params=None, proxies=d auth = self.auth with requests.get( - url, + fullurl, stream=True, auth=auth, headers=headers, @@ -595,7 +595,7 @@ def downloadFile(self, url, destination, overwrite=False, params=None, proxies=d headers = response.headers - if 'Content-Type' in headers and 'text/html' in headers['Content-Type']: + if not url.startswith('media/report') and not url.startswith('media/label') and 'Content-Type' in headers and 'text/html' in headers['Content-Type']: logger.error(f"Error downloading file '{url}': Server return invalid response (text/html)") return False diff --git a/inventree/label.py b/inventree/label.py index 271a5fc..f9585b0 100644 --- a/inventree/label.py +++ b/inventree/label.py @@ -77,19 +77,93 @@ def printlabel(self, label, plugin=None, destination=None, *args, **kwargs): return response -class LabelLocation(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class LabelFunctions(inventree.base.MetadataMixin, inventree.base.InventreeObject): + """Base class for label functions""" + + @classmethod + def create(cls, api, data, label, **kwargs): + """Create a new label by uploading a label template file. Convenience wrapper around base create() method. + + Args: + data: Dict of data including at least name and description for the template + label: Either a string (filename) or a file object + """ + + # POST endpoints for creating new reports were added in API version 156 + cls.REQUIRED_API_VERSION = 156 + + try: + # If label is already a readable object, don't convert it + if label.readable() is False: + raise ValueError("Label template file must be readable") + except AttributeError: + label = open(label) + if label.readable() is False: + raise ValueError("Label template file must be readable") + + try: + response = super().create(api, data=data, files={'label': label}, **kwargs) + finally: + if label is not None: + label.close() + return response + + def save(self, data=None, label=None, **kwargs): + """Save label to database. Convenience wrapper around save() method. + + Args: + data (optional): Dict of data to change for the template. + label (optional): Either a string (filename) or a file object, to upload a new label template + """ + + # PUT/PATCH endpoints for updating data were available before POST endpoints + self.REQUIRED_API_VERSION = None + + if label is not None: + try: + # If template is already a readable object, don't convert it + if label.readable() is False: + raise ValueError("Label template file must be readable") + except AttributeError: + label = open(label, 'r') + if label.readable() is False: + raise ValueError("Label template file must be readable") + + if 'files' in kwargs: + files = kwargs.pop('kwargs') + files['label'] = label + else: + files = {'label': label} + else: + files = None + + try: + response = super().save(data=data, files=files) + finally: + if label is not None: + label.close() + return response + + def downloadTemplate(self, destination, overwrite=False): + """Download template file for the label to the given destination""" + + # Use downloadFile method to get the file + return self._api.downloadFile(url=self._data['label'], destination=destination, overwrite=overwrite) + + +class LabelLocation(LabelFunctions): """ Class representing the Label/Location database model """ URL = 'label/location' -class LabelPart(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class LabelPart(LabelFunctions): """ Class representing the Label/Part database model """ URL = 'label/part' -class LabelStock(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class LabelStock(LabelFunctions): """ Class representing the Label/stock database model """ URL = 'label/stock' diff --git a/inventree/report.py b/inventree/report.py index b6ac3f8..a73969a 100644 --- a/inventree/report.py +++ b/inventree/report.py @@ -50,43 +50,120 @@ def printreport(self, report, destination, *args, **kwargs): return self._api.downloadFile(url=URL, destination=destination, params=params, *args, **kwargs) -class ReportBoM(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class ReportFunctions(inventree.base.MetadataMixin, inventree.base.InventreeObject): + """Base class for report functions""" + + @classmethod + def create(cls, api, data, template, **kwargs): + """Create a new report by uploading a template file. Convenience wrapper around base create() method. + + Args: + data: Dict of data including at least name and description for the template + template: Either a string (filename) or a file object + """ + + # POST endpoints for creating new reports were added in API version 156 + cls.REQUIRED_API_VERSION = 156 + + try: + # If template is already a readable object, don't convert it + if template.readable() is False: + raise ValueError("Template file must be readable") + except AttributeError: + template = open(template) + if template.readable() is False: + raise ValueError("Template file must be readable") + + try: + response = super().create(api, data=data, files={'template': template}, **kwargs) + finally: + if template is not None: + template.close() + return response + + def save(self, data=None, template=None, **kwargs): + """Save report data to database. Convenience wrapper around save() method. + + Args: + data (optional): Dict of data to change for the template. + template (optional): Either a string (filename) or a file object, to upload a new template + """ + + # PUT/PATCH endpoints for updating data were available before POST endpoints + self.REQUIRED_API_VERSION = None + + if template is not None: + try: + # If template is already a readable object, don't convert it + if template.readable() is False: + raise ValueError("Template file must be readable") + except AttributeError: + template = open(template, 'r') + if template.readable() is False: + raise ValueError("Template file must be readable") + + if 'files' in kwargs: + files = kwargs.pop('kwargs') + files['template'] = template + else: + files = {'template': template} + else: + files = None + + try: + response = super().save(data=data, files=files) + finally: + if template is not None: + template.close() + return response + + def downloadTemplate(self, destination, overwrite=False): + """Download template file for the report to the given destination""" + + # Use downloadFile method to get the file + return self._api.downloadFile(url=self._data['template'], destination=destination, overwrite=overwrite) + + +class ReportBoM(ReportFunctions): """Class representing ReportBoM""" URL = 'report/bom' -class ReportBuild(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class ReportBuild(ReportFunctions): """Class representing ReportBuild""" URL = 'report/build' -class ReportPurchaseOrder(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class ReportPurchaseOrder(ReportFunctions): """Class representing ReportPurchaseOrder""" URL = 'report/po' -class ReportSalesOrder(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class ReportSalesOrder(ReportFunctions): """Class representing ReportSalesOrder""" URL = 'report/so' -class ReportReturnOrder(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class ReportReturnOrder(ReportFunctions): """Class representing ReportReturnOrder""" URL = 'report/ro' -class ReportStockLocation(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class ReportStockLocation(ReportFunctions): """Class representing ReportStockLocation""" + # The Stock location report was added when API version was 127, but the API version was not incremented at the same time + # The closest API version which has the SLR report is 128 + REQUIRED_API_VERSION = 128 URL = 'report/slr' -class ReportTest(inventree.base.MetadataMixin, inventree.base.InventreeObject): +class ReportTest(ReportFunctions): """Class representing ReportTest""" URL = 'report/test' diff --git a/test/dummytemplate.html b/test/dummytemplate.html new file mode 100644 index 0000000..09c8f42 --- /dev/null +++ b/test/dummytemplate.html @@ -0,0 +1,5 @@ +{% extends "label/label_base.html" %} + +
TEST LABEL
+ +{% endblock content %} diff --git a/test/dummytemplate2.html b/test/dummytemplate2.html new file mode 100644 index 0000000..4eb2fbe --- /dev/null +++ b/test/dummytemplate2.html @@ -0,0 +1,5 @@ +{% extends "label/label_base.html" %} + +
TEST LABEL TWO
+ +{% endblock content %} diff --git a/test/test_label.py b/test/test_label.py index 0b6eeb0..224a7c3 100644 --- a/test/test_label.py +++ b/test/test_label.py @@ -56,6 +56,105 @@ def test_label_list(self): self.assertGreater(len(lbl_location_list_filtered), 0) self.assertEqual(lbl_location_list, lbl_location_list_filtered) + def test_label_create_download(self): + """ + Tests creating a new label from API, by uploading the dummy template file + """ + + def comparefiles(file1, file2): + """Compare content of two files, return True if equal, else False""" + + F1 = open(file1, 'r') + contents_1 = F1.read() + F1.close() + + F2 = open(file2, 'r') + contents_2 = F2.read() + F2.close() + + return contents_1 == contents_2 + + dummytemplate = os.path.join(os.path.dirname(__file__), 'dummytemplate.html') + dummytemplate2 = os.path.join(os.path.dirname(__file__), 'dummytemplate2.html') + + for RepClass in (LabelPart, LabelStock, LabelLocation): + # Test for all Label classes sequentially + + # + # Test with file name + # + + # Create a new label based on the dummy template + newlabel = RepClass.create( + self.api, + {'name': 'Dummy label', 'description': 'Label created as test'}, + dummytemplate + ) + + # The return value should be a LabelPart object + self.assertIsInstance(newlabel, RepClass) + + # Try to download the template file + newlabel.downloadTemplate(destination="dummytemplate_download.html") + + self.assertTrue(comparefiles("dummytemplate_download.html", dummytemplate)) + + # Remove the test file + os.remove("dummytemplate_download.html") + + # + # Test with open(...) + # + + # Create a new label based on the dummy template + with open(dummytemplate) as template_upload: + newlabel2 = RepClass.create( + self.api, + {'name': 'Dummy label', 'description': 'Label created as test'}, + template_upload + ) + + # The return value should be a LabelPart object + self.assertIsInstance(newlabel2, RepClass) + + # Try to download the template file + newlabel2.downloadTemplate(destination="dummytemplate_download.html") + + self.assertTrue(comparefiles("dummytemplate_download.html", dummytemplate)) + + # Remove the test file + os.remove("dummytemplate_download.html") + + # + # Test overwriting the label file with save method + # Use file name + # + + newlabel2.save(data=None, label=dummytemplate2) + + # Try to download the template file + newlabel2.downloadTemplate(destination="dummytemplate2_download.html") + + self.assertTrue(comparefiles("dummytemplate2_download.html", dummytemplate2)) + # Remove the test file + os.remove("dummytemplate2_download.html") + + # + # Test overwriting the template file with save method + # Use open(...) + # + + with open(dummytemplate) as template_upload: + newlabel2.save(data=None, label=template_upload) + + # Try to download the template file + newlabel2.downloadTemplate(destination="dummytemplate_download.html") + + self.assertTrue(comparefiles("dummytemplate_download.html", dummytemplate)) + + # Remove the test file + os.remove("dummytemplate_download.html") + def test_label_printing(self): """ Tests for using label printing function to download PDF files diff --git a/test/test_report.py b/test/test_report.py index 021d1ef..cb894fa 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -6,13 +6,17 @@ sys.path.append(os.path.abspath(os.path.dirname(__file__))) from test_api import InvenTreeTestCase # noqa: E402 -from inventree.part import BomItem # noqa: E402 + from inventree.build import Build # noqa: E402 +from inventree.part import BomItem # noqa: E402 from inventree.purchase_order import PurchaseOrder # noqa: E402 -from inventree.sales_order import SalesOrder # noqa: E402 +from inventree.report import (ReportBoM, ReportBuild, # noqa: E402 + ReportPurchaseOrder, ReportReturnOrder, + ReportSalesOrder, ReportStockLocation, + ReportTest) from inventree.return_order import ReturnOrder # noqa: E402 -from inventree.stock import StockLocation, StockItemTestResult # noqa: E402 -from inventree.report import (ReportBoM, ReportBuild, ReportPurchaseOrder, ReportSalesOrder, ReportReturnOrder, ReportStockLocation, ReportTest) # noqa: E402 +from inventree.sales_order import SalesOrder # noqa: E402 +from inventree.stock import StockItemTestResult, StockLocation # noqa: E402 class ReportClassesTest(InvenTreeTestCase): @@ -35,6 +39,112 @@ def test_report_list(self): self.assertGreater(len(report_list_filtered), 0) self.assertEqual(report_list, report_list_filtered) + def test_report_create_download(self): + """ + Tests creating a new report from API, by uploading the dummy template file + """ + + def comparefiles(file1, file2): + """Compare content of two files, return True if equal, else False""" + + F1 = open(file1, 'r') + contents_1 = F1.read() + F1.close() + + F2 = open(file2, 'r') + contents_2 = F2.read() + F2.close() + + return contents_1 == contents_2 + + dummytemplate = os.path.join(os.path.dirname(__file__), 'dummytemplate.html') + dummytemplate2 = os.path.join(os.path.dirname(__file__), 'dummytemplate2.html') + + # Not testing ReportBuild, since a bug (https://github.com/inventree/InvenTree/issues/6213) prevents the PATCH + # method from working + for RepClass in (ReportBoM, ReportPurchaseOrder, ReportSalesOrder, ReportReturnOrder, ReportStockLocation, ReportTest): + # Test for all Label classes sequentially + + # + # Test with file name + # + + # Create a new label based on the dummy template + newreport = RepClass.create( + self.api, + {'name': 'Dummy report', 'description': 'Report created as test'}, + dummytemplate + ) + + # The return value should be a LabelPart object + self.assertIsInstance(newreport, RepClass) + + # Try to download the template file + newreport.downloadTemplate(destination="dummytemplate_download.html") + + # Compare file contents, make sure they're the same + self.assertTrue(comparefiles("dummytemplate_download.html", dummytemplate)) + + # Remove the test file + os.remove("dummytemplate_download.html") + + # + # Test with open(...) + # + + # Create a new label based on the dummy template + with open(dummytemplate) as template_upload: + newreport2 = RepClass.create( + self.api, + {'name': 'Dummy report', 'description': 'Report created as test'}, + template_upload + ) + + # The return value should be a LabelPart object + self.assertIsInstance(newreport2, RepClass) + + # Try to download the template file + newreport2.downloadTemplate(destination="dummytemplate_download.html") + + # Compare file contents, make sure they're the same + # Compare file contents, make sure they're the same + self.assertTrue(comparefiles("dummytemplate_download.html", dummytemplate)) + + # Remove the test file + os.remove("dummytemplate_download.html") + + # + # Test overwriting the label file with save method + # Use file name + # + newreport2.save(data=None, template=dummytemplate2) + + # Try to download the template file + newreport2.downloadTemplate(destination="dummytemplate2_download.html") + + # Compare file contents, make sure they're the same + self.assertTrue(comparefiles("dummytemplate2_download.html", dummytemplate2)) + + # Remove the test file + os.remove("dummytemplate2_download.html") + + # + # Test overwriting the template file with save method + # Use open(...) + # + + with open(dummytemplate) as template_upload: + newreport2.save(data=None, template=template_upload) + + # Try to download the template file + newreport2.downloadTemplate(destination="dummytemplate_download.html") + + # Compare file contents, make sure they're the same + self.assertTrue(comparefiles("dummytemplate_download.html", dummytemplate)) + + # Remove the test file + os.remove("dummytemplate_download.html") + def test_report_printing(self): """ Tests for using report printing function to download PDF files