diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0c806f9e..94da4546 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,7 +9,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.8] + python-version: [3.11] steps: - name: Checkout Code @@ -23,5 +23,5 @@ jobs: pip install -U -r requirements.txt - name: Build Python Package run: | - pip install --upgrade pip wheel setuptools - python setup.py bdist_wheel --universal + pip install --upgrade pip wheel setuptools build + python3 -m build diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ebc4ddbb..c5b41eab 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,22 +8,26 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INVENTREE_SITE_URL: http://localhost:8000 INVENTREE_DB_ENGINE: django.db.backends.sqlite3 INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3 INVENTREE_MEDIA_ROOT: ../test_inventree_media INVENTREE_STATIC_ROOT: ../test_inventree_static INVENTREE_BACKUP_DIR: ../test_inventree_backup + INVENTREE_COOKIE_SAMESITE: False INVENTREE_ADMIN_USER: testuser INVENTREE_ADMIN_PASSWORD: testpassword INVENTREE_ADMIN_EMAIL: test@test.com INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345 INVENTREE_PYTHON_TEST_USERNAME: testuser INVENTREE_PYTHON_TEST_PASSWORD: testpassword + INVENTREE_DEBUG: True + INVENTREE_LOG_LEVEL: DEBUG strategy: max-parallel: 4 matrix: - python-version: [3.9] + python-version: [3.11] steps: - name: Checkout Code @@ -44,7 +48,7 @@ jobs: invoke install invoke migrate invoke dev.import-fixtures - invoke dev.server -a 127.0.0.1:12345 & + invoke dev.server -a 0.0.0.0:12345 & invoke wait - name: Run Tests run: | diff --git a/.github/workflows/pep.yaml b/.github/workflows/pep.yaml index b367602c..e7a1e730 100644 --- a/.github/workflows/pep.yaml +++ b/.github/workflows/pep.yaml @@ -9,7 +9,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.8] + python-version: [3.11] steps: - name: Checkout Code diff --git a/.github/workflows/pypi.yaml b/.github/workflows/pypi.yaml index e7473f48..0d092e76 100644 --- a/.github/workflows/pypi.yaml +++ b/.github/workflows/pypi.yaml @@ -18,17 +18,17 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.11 - name: Check Release Tag run: | python3 ci/check_version_number.py ${{ github.event.release.tag_name }} - name: Install Python Dependencies run: | pip install -U -r requirements.txt - pip install --upgrade wheel setuptools twine + pip install --upgrade wheel setuptools twine build - name: Build Binary run: | - python3 setup.py sdist bdist_wheel --universal + python3 -m build - name: Publish run: | python3 -m twine upload dist/* diff --git a/README.md b/README.md index 840c0cae..85b26c9e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,14 @@ The InvenTree python library can be easily installed using PIP: pip install inventree ``` +If you need to rely on system certificates from the OS certificate store instead of the bundled certificates, use + +``` +pip install inventree[system-certs] +``` + +This allows pip and Python applications to verify TLS/SSL connections to servers whose certificates are trusted by your system, and can be helpful if you're using a custom certificate authority (CA) for your InvenTree instance's cert. + ## Documentation Refer to the [InvenTree documentation](https://docs.inventree.org/en/latest/api/python/python/) diff --git a/inventree/api.py b/inventree/api.py index 38fe6c00..fca36a9f 100644 --- a/inventree/api.py +++ b/inventree/api.py @@ -78,7 +78,7 @@ def __init__(self, host=None, **kwargs): if kwargs.get('connect', True): self.connect() - def setHostName(self, host): + def setHostName(self, host: str): """Validate that the provided base URL is valid""" if host is None: @@ -136,7 +136,7 @@ def connect(self): if not self.token: self.requestToken() - def constructApiUrl(self, endpoint_url): + def constructApiUrl(self, endpoint_url: str) -> str: """Construct an API endpoint URL based on the provided API URL. Arguments: @@ -145,17 +145,7 @@ def constructApiUrl(self, endpoint_url): Returns: A fully qualified URL for the subsequent request """ - # Strip leading / character if provided - if endpoint_url.startswith("/"): - endpoint_url = endpoint_url[1:] - - url = urljoin(self.api_url, endpoint_url) - - # Ensure the API URL ends with a trailing slash - if not url.endswith('/'): - url += '/' - - return url + return urljoin(self.api_url, endpoint_url) def testAuth(self): """ @@ -169,7 +159,7 @@ def testAuth(self): return False try: - response = self.get('/user/me/') + response = self.get('user/me/') except requests.exceptions.HTTPError as e: logger.fatal(f"Authentication error: {str(type(e))}") return False @@ -196,7 +186,12 @@ def testServer(self): logger.info("Checking InvenTree server connection...") try: - response = requests.get(self.api_url, timeout=self.timeout, proxies=self.proxies) + response = requests.get( + self.api_url, + timeout=self.timeout, + proxies=self.proxies, + verify=self.strict + ) except requests.exceptions.ConnectionError as e: logger.fatal(f"Server connection error: {str(type(e))}") return False @@ -247,9 +242,13 @@ def requestToken(self): return False # Request an auth token from the server + if self.api_version < 490: + token_url = 'user/token/' + else: + token_url = 'user/me/token/' try: response = self.get( - '/user/token/', + token_url, params={ 'name': self.token_name, } @@ -268,14 +267,14 @@ def requestToken(self): return self.token - def request(self, api_url, **kwargs): + def request(self, url: str, **kwargs): """ Perform a URL request to the Inventree API """ if not self.connected: # If we have not established a connection to the server yet, attempt now self.connect() - api_url = self.constructApiUrl(api_url) + api_url = self.constructApiUrl(url) data = kwargs.get('data', kwargs.get('json', {})) files = kwargs.get('files', {}) @@ -396,7 +395,7 @@ def request(self, api_url, **kwargs): return response - def delete(self, url, **kwargs): + def delete(self, url: str, **kwargs): """ Perform a DELETE request. Used to remove a record in the database. """ @@ -415,7 +414,7 @@ def delete(self, url, **kwargs): return response - def post(self, url, data, **kwargs): + def post(self, url: str, data: dict, **kwargs): """ Perform a POST request. Used to create a new record in the database. Args: @@ -452,7 +451,7 @@ def post(self, url, data, **kwargs): return data - def patch(self, url, data, **kwargs): + def patch(self, url: str, data: dict, **kwargs): """ Perform a PATCH request. @@ -490,7 +489,7 @@ def patch(self, url, data, **kwargs): return data - def put(self, url, data, **kwargs): + def put(self, url: str, data: dict, **kwargs): """ Perform a PUT request. Used to update existing records in the database. @@ -526,7 +525,7 @@ def put(self, url, data, **kwargs): return data - def get(self, url, **kwargs): + def get(self, url: str, **kwargs): """ Perform a GET request. For argument information, refer to the 'request' method @@ -583,13 +582,15 @@ def downloadFile(self, url, destination, overwrite=False, params=None, proxies=d auth = self.auth with requests.get( - fullurl, - stream=True, - auth=auth, - headers=headers, - params=params, - timeout=self.timeout, - proxies=self.proxies) as response: + fullurl, + stream=True, + auth=auth, + headers=headers, + params=params, + timeout=self.timeout, + proxies=self.proxies, + verify=self.strict, + ) as response: # Error code if response.status_code >= 300: @@ -626,7 +627,7 @@ def scanBarcode(self, barcode_data): barcode_data = json.dumps(barcode_data) response = self.post( - '/barcode/', + 'barcode/', { 'barcode': str(barcode_data), } diff --git a/inventree/base.py b/inventree/base.py index 4a56c486..e0a37148 100644 --- a/inventree/base.py +++ b/inventree/base.py @@ -2,12 +2,13 @@ import json import logging -import requests import os +import requests + from . import api as inventree_api -INVENTREE_PYTHON_VERSION = "0.17.2" +INVENTREE_PYTHON_VERSION = "0.23.2" logger = logging.getLogger('inventree') @@ -20,9 +21,15 @@ class InventreeObject(object): URL = "" @classmethod - def get_url(cls, api): + def get_url(cls): """Helper method to get the URL associated with this model.""" - return cls.URL + + url = cls.URL + + if not url.endswith('/'): + url += '/' + + return url # Minimum server version for the particular model type MIN_API_VERSION = None @@ -54,7 +61,7 @@ def pk(self): # Coerce 'pk' values to integer if self.getPkField() == 'pk': val = int(val) - + return val def __str__(self): @@ -86,11 +93,11 @@ def __init__(self, api, pk=None, data=None): pk = int(str(pk).strip()) except Exception: raise TypeError(f"Invalid primary key value '{pk}' for {self.__class__}") - + if pk <= 0: raise ValueError(f"Supplier value ({pk}) for {self.__class__} must be positive.") - url = self.get_url(api) + url = self.get_url() self._url = f"{url}/{pk}/" self._api = api @@ -107,7 +114,13 @@ def __init__(self, api, pk=None, data=None): @classmethod def getModelType(cls): """Return the model type for this label printing class.""" - return cls.MODEL_TYPE + model_type = cls.MODEL_TYPE + + if not model_type: + # Default to the class name (lower case) if the model type is not explicitly defined + model_type = cls.__name__.lower() + + return model_type @classmethod def checkApiVersion(cls, api): @@ -134,7 +147,7 @@ def options(cls, api): cls.checkApiVersion(api) response = api.request( - cls.URL, + cls.get_url(), method='OPTIONS', ) @@ -346,11 +359,17 @@ def __contains__(self, name): return name in self._data def __getattr__(self, name): + try: + data = object.__getattribute__(self, "_data") + except AttributeError: + # Appears to happen during pickling. Raise immediately to prevent recursion errors + raise AttributeError(name) - if name in self._data.keys(): - return self._data[name] - else: - return super().__getattribute__(name) + if name in data: + return data[name] + + # if we're in this block, there already wasn't a "normal" attribute with this name. Raise + raise AttributeError(name) def __getitem__(self, name): if name in self._data.keys(): @@ -500,13 +519,14 @@ def download(self, destination, **kwargs): class AttachmentMixin: """Mixin class which allows a model class to interact with attachments.""" - def getAttachments(self): + def getAttachments(self, **kwargs): """Return a list of attachments associated with this object.""" return Attachment.list( self._api, model_type=self.getModelType(), - model_id=self.pk + model_id=self.pk, + **kwargs ) def uploadAttachment(self, attachment, comment=""): @@ -519,7 +539,7 @@ def uploadAttachment(self, attachment, comment=""): model_type=self.getModelType(), model_id=self.pk ) - + def addLinkAttachment(self, link, comment=""): """Add an external link attachment against this Object. @@ -537,21 +557,68 @@ def addLinkAttachment(self, link, comment=""): ) +class Parameter(BulkDeleteMixin, InventreeObject): + """Class representing a custom parameter object.""" + + URL = "parameter/" + + # Ref: https://github.com/inventree/InvenTree/pull/10699 + MIN_API_VERSION = 429 + + +class ParameterTemplate(InventreeObject): + """Class representing a parameter template object.""" + + URL = "parameter/template/" + + # Ref: https://github.com/inventree/InvenTree/pull/10699 + MIN_API_VERSION = 429 + + +class ParameterMixin: + """Mixin class which allows a model class to interact with parameters. + + Ref: https://github.com/inventree/InvenTree/pull/10699 + """ + + def getParameters(self, **kwargs): + """Return a list of parameters associated with this object.""" + + if self._api.api_version < Parameter.MIN_API_VERSION: + raise NotImplementedError(f"Server API Version ({self._api.api_version}) is too old for ParameterMixin, which requires API version >= {Parameter.MIN_API_VERSION}") + + return Parameter.list( + self._api, + model_type=self.getModelType(), + model_id=self.pk, + **kwargs + ) + + class MetadataMixin: """Mixin class for models which support a 'metadata' attribute. - The 'metadata' is not used for any InvenTree business logic - Instead it can be used by plugins for storing arbitrary information - Internally it is stored as a JSON database field - - Metadata is accessed via the API by appending '/metadata/' to the API URL + - Metadata is accessed via the API by appending './metadata/' to the API URL Note: Requires server API version 49 or newer """ + NEW_METADATA_API_VERSION = 436 @property def metadata_url(self): - return os.path.join(self._url, "metadata/") + """Return the metadata URL for this model instance.""" + + # Legacy metadata API endpoints + if self._api.api_version < self.NEW_METADATA_API_VERSION: + return os.path.join(self._url, "metadata/") + + model_type = self.getModelType() + + return f"metadata/{model_type}/{self.pk}/" def getMetadata(self): """Read model instance metadata""" @@ -668,7 +735,7 @@ def _statusupdate(self, status: str, reload=True, data=None, **kwargs): raise ValueError(f"Order stats {status} not supported.") # Set the url - URL = self.URL + f"/{self.pk}/{status}" + URL = self.URL + f"/{self.pk}/{status}/" if data is None: data = {} @@ -718,7 +785,7 @@ def assignBarcode(self, barcode_data: str, reload=True): model_type = self.barcodeModelType() response = self._api.post( - '/barcode/link/', + 'barcode/link/', { 'barcode': barcode_data, model_type: self.pk, @@ -730,13 +797,13 @@ def assignBarcode(self, barcode_data: str, reload=True): return response - def unassignBarcode(self, reload=True): + def unassignBarcode(self, reload: bool = True): """Unassign a barcode from this object""" model_type = self.barcodeModelType() response = self._api.post( - '/barcode/unlink/', + 'barcode/unlink/', { model_type: self.pk, } diff --git a/inventree/build.py b/inventree/build.py index 9b05d82c..6c60b037 100644 --- a/inventree/build.py +++ b/inventree/build.py @@ -2,10 +2,12 @@ import inventree.base import inventree.report +import inventree.stock class Build( inventree.base.AttachmentMixin, + inventree.base.ParameterMixin, inventree.base.StatusMixin, inventree.base.MetadataMixin, inventree.report.ReportPrintingMixin, @@ -13,13 +15,13 @@ class Build( ): """ Class representing the Build database model """ - URL = 'build' + URL = 'build/' MODEL_TYPE = 'build' def issue(self): """Mark this build as 'issued'.""" return self._statusupdate(status='issue') - + def hold(self): """Mark this build as 'on hold'.""" return self._statusupdate(status='hold') @@ -47,3 +49,137 @@ def complete( def finish(self, *args, **kwargs): """Alias for complete""" return self.complete(*args, **kwargs) + + def getLines(self, **kwargs): + """ Return the build line items associated with this build order """ + return BuildLine.list(self._api, build=self.pk, **kwargs) + + def getBuildOutputs(self, complete: bool = None, **kwargs): + """ Return the build output items associated with this build order + + Arguments: + - complete: If not None, filter the build outputs by their 'complete' status + """ + if complete is not None: + kwargs['is_building'] = not complete + + # Find stock items which are marked as 'outputs' of this build order + return inventree.stock.StockItem.list( + self._api, + build=self.pk, + **kwargs + ) + + def createBuildOutput(self, **kwargs): + """ Create a new build output (stock item) associated with this build order """ + result = self._api.post( + f'{self.URL}{self.pk}/create-output/', + data={ + **kwargs + } + ) + + # Note: The response is a list of created stock items + return [inventree.stock.StockItem(self._api, item['pk'], item) for item in result] + + def cancelBuildOutputs(self, outputs): + """ Cancel a build output item associated with this build order + + Arguments: + - outputs: The StockItem object (or list of StockItem objects) to cancel + """ + + if not isinstance(outputs, list): + outputs = [outputs] + + return self._api.post( + f'{self.URL}{self.pk}/delete-outputs/', + data={ + 'outputs': [ + {'output': output.pk} for output in outputs + ] + } + ) + + def scrapBuildOutput(self, output, **kwargs): + """ Scrap a single build output item associated with this build order + + Arguments: + - output: The StockItem object to scrap + """ + + data = { + **kwargs, + 'outputs': [ + { + 'output': output.pk, + 'quantity': kwargs.get('quantity', output.quantity), + } + ] + } + + data['location'] = kwargs.get('location', output.location) + + return self._api.post( + f'{self.URL}{self.pk}/scrap-outputs/', + data=data + ) + + def completeBuildOutput(self, output, **kwargs): + """ Mark a single build output item as complete + + Arguments: + - output: The StockItem object to mark as complete + """ + + data = { + **kwargs, + 'outputs': [ + { + 'output': output.pk, + 'quantity': kwargs.get('quantity', output.quantity), + } + ] + } + + # If a location is not specified, use the current location of the stock item + data['location'] = kwargs.get('location', output.location) + + return self._api.post( + f'{self.URL}{self.pk}/complete/', + data=data + ) + + +class BuildLine( + inventree.base.InventreeObject, +): + """ Class representing the BuildLine database model """ + + URL = 'build/line/' + MODEL_TYPE = 'buildline' + + def getBuild(self): + """Return the Build object associated with this line item""" + return Build(self._api, self.build) + + +class BuildItem( + inventree.base.InventreeObject, +): + """ Class representing the BuildItem database model """ + + URL = 'build/item/' + MODEL_TYPE = 'builditem' + + def getBuild(self): + """Return the Build object associated with this build item""" + return Build(self._api, self.build) + + def getBuildLine(self): + """Return the BuildLine object associated with this build item""" + return BuildLine(self._api, self.build_line) + + def getStockItem(self): + """Return the StockItem object associated with this build item""" + return inventree.stock.StockItem(self._api, self.stock_item) diff --git a/inventree/company.py b/inventree/company.py index ab88c194..485062e5 100644 --- a/inventree/company.py +++ b/inventree/company.py @@ -13,6 +13,7 @@ class Contact(inventree.base.InventreeObject): URL = 'company/contact/' MIN_API_VERSION = 104 + MODEL_TYPE = 'contact' class Address(inventree.base.InventreeObject): @@ -20,12 +21,19 @@ class Address(inventree.base.InventreeObject): URL = 'company/address/' MIN_API_VERSION = 126 + MODEL_TYPE = 'address' -class Company(inventree.base.ImageMixin, inventree.base.MetadataMixin, inventree.base.InventreeObject): +class Company( + inventree.base.AttachmentMixin, + inventree.base.ParameterMixin, + inventree.base.ImageMixin, + inventree.base.MetadataMixin, + inventree.base.InventreeObject +): """ Class representing the Company database model """ - URL = 'company' + URL = 'company/' MODEL_TYPE = "company" def getContacts(self, **kwargs): @@ -97,13 +105,21 @@ def createReturnOrder(self, **kwargs): return inventree.order.ReturnOrder.create(self._api, data=kwargs) -class SupplierPart(inventree.base.BarcodeMixin, inventree.base.BulkDeleteMixin, inventree.base.MetadataMixin, inventree.base.InventreeObject): +class SupplierPart( + inventree.base.AttachmentMixin, + inventree.base.ParameterMixin, + inventree.base.BarcodeMixin, + inventree.base.BulkDeleteMixin, + inventree.base.MetadataMixin, + inventree.base.InventreeObject +): """Class representing the SupplierPart database model - Implements the BulkDeleteMixin """ - URL = 'company/part' + URL = 'company/part/' + MODEL_TYPE = "supplierpart" def getPriceBreaks(self): """ Get a list of price break objects for this SupplierPart """ @@ -113,6 +129,7 @@ def getPriceBreaks(self): class ManufacturerPart( inventree.base.AttachmentMixin, + inventree.base.ParameterMixin, inventree.base.BulkDeleteMixin, inventree.base.MetadataMixin, inventree.base.InventreeObject, @@ -122,7 +139,7 @@ class ManufacturerPart( - Implements the BulkDeleteMixin """ - URL = 'company/part/manufacturer' + URL = 'company/part/manufacturer/' MODEL_TYPE = "manufacturerpart" def getParameters(self, **kwargs): @@ -130,19 +147,25 @@ def getParameters(self, **kwargs): GET a list of all ManufacturerPartParameter objects for this ManufacturerPart """ - return ManufacturerPartParameter.list(self._api, manufacturer_part=self.pk, **kwargs) + # Support legacy API version which uses a different endpoint + if self._api.api_version < inventree.base.Parameter.MIN_API_VERSION: + return ManufacturerPartParameter.list(self._api, manufacturer_part=self.pk, **kwargs) + + return super().getParameters(**kwargs) class ManufacturerPartParameter(inventree.base.BulkDeleteMixin, inventree.base.InventreeObject): """Class representing the ManufacturerPartParameter database model. - - Implements the BulkDeleteMixin + Note: This class was removed in API version 418 and later. + Ref: https://github.com/inventree/InvenTree/pull/10699 """ - URL = 'company/part/manufacturer/parameter' + URL = 'company/part/manufacturer/parameter/' + MAX_API_VERSION = 428 class SupplierPriceBreak(inventree.base.InventreeObject): """ Class representing the SupplierPriceBreak database model """ - URL = 'company/price-break' + URL = 'company/price-break/' diff --git a/inventree/label.py b/inventree/label.py index 8beba797..91d3fe11 100644 --- a/inventree/label.py +++ b/inventree/label.py @@ -39,7 +39,7 @@ def saveOutput(self, output, filename): def printLabel(self, template, plugin=None, destination=None, *args, **kwargs): """Print a label against the provided label template.""" - print_url = '/label/print/' + print_url = 'label/print/' template_id = self.getTemplateId(template) @@ -161,7 +161,7 @@ def downloadTemplate(self, destination, overwrite=False): class LabelTemplate(LabelFunctions): """Class representing the LabelTemplate database model.""" - URL = 'label/template' + URL = 'label/template/' def __str__(self): """String representation of the LabelTemplate instance.""" diff --git a/inventree/order.py b/inventree/order.py index e9cb2fc7..8228c114 100644 --- a/inventree/order.py +++ b/inventree/order.py @@ -16,3 +16,4 @@ from inventree.sales_order import SalesOrderExtraLineItem # noqa:F401 from inventree.sales_order import SalesOrderLineItem # noqa:F401 from inventree.sales_order import SalesOrderShipment # noqa:F401 +from inventree.sales_order import SalesOrderAllocation # noqa:F401 diff --git a/inventree/part.py b/inventree/part.py index 526d780a..a2de68fe 100644 --- a/inventree/part.py +++ b/inventree/part.py @@ -16,7 +16,7 @@ class PartCategoryParameterTemplate(inventree.base.InventreeObject): """A model which link a ParameterTemplate to a PartCategory""" - URL = 'part/category/parameters' + URL = 'part/category/parameters/' def getCategory(self): """Return the referenced PartCategory instance""" @@ -24,13 +24,21 @@ def getCategory(self): def getTemplate(self): """Return the referenced ParameterTemplate instance""" - return ParameterTemplate(self._api, self.parameter_template) + + template_id = getattr(self, 'template', None) or getattr(self, 'parameter_template', None) + + if self._api.api_version < inventree.base.ParameterTemplate.MIN_API_VERSION: + # Return legacy PartParameterTemplate object + return PartParameterTemplate(self._api, template_id) + + return inventree.base.ParameterTemplate(self._api, template_id) class PartCategory(inventree.base.MetadataMixin, inventree.base.InventreeObject): """ Class representing the PartCategory database model """ - URL = 'part/category' + URL = 'part/category/' + MODEL_TYPE = 'partcategory' def getParts(self, **kwargs): return Part.list(self._api, category=self.pk, **kwargs) @@ -60,6 +68,7 @@ def getCategoryParameterTemplates(self, fetch_parent: bool = True) -> list: class Part( inventree.base.AttachmentMixin, + inventree.base.ParameterMixin, inventree.base.BarcodeMixin, inventree.base.MetadataMixin, inventree.base.ImageMixin, @@ -68,7 +77,7 @@ class Part( ): """ Class representing the Part database model """ - URL = 'part' + URL = 'part/' MODEL_TYPE = 'part' def getCategory(self): @@ -108,7 +117,12 @@ def getStockItems(self, **kwargs): def getParameters(self): """ Return parameters associated with this part """ - return Parameter.list(self._api, part=self.pk) + + if self._api.api_version < inventree.base.Parameter.MIN_API_VERSION: + # Return legacy PartParameter objects + return PartParameter.list(self._api, part=self.pk) + + return super().getParameters() def getRelated(self): """ Return related parts associated with this part """ @@ -127,7 +141,7 @@ def setInternalPrice(self, quantity: int, price: float): """ return InternalPrice.setInternalPrice(self._api, self.pk, quantity, price) - + def getSalePrice(self): """ Get sales prices for this part @@ -149,7 +163,7 @@ def getRequirements(self): class PartTestTemplate(inventree.base.MetadataMixin, inventree.base.InventreeObject): """ Class representing a test template for a Part """ - URL = 'part/test-template' + URL = 'part/test-template/' @classmethod def generateTestKey(cls, test_name): @@ -183,13 +197,23 @@ class BomItem( ): """ Class representing the BomItem database model """ - URL = 'bom' + URL = 'bom/' + MODEL_TYPE = 'bomitem' + + +class BomItemSubstitute( + inventree.base.InventreeObject, + inventree.base.MetadataMixin, +): + """Class representing the BomItemSubstitute database model""" + + URL = "bom/substitute/" class InternalPrice(inventree.base.InventreeObject): """ Class representing the InternalPrice model """ - URL = 'part/internal-price' + URL = 'part/internal-price/' @classmethod def setInternalPrice(cls, api, part, quantity: int, price: float): @@ -210,7 +234,7 @@ def setInternalPrice(cls, api, part, quantity: int, price: float): class SalePrice(inventree.base.InventreeObject): """ Class representing the SalePrice model """ - URL = 'part/sale-price' + URL = 'part/sale-price/' @classmethod def setSalePrice(cls, api, part, quantity: int, price: float, price_currency: str): @@ -232,7 +256,7 @@ def setSalePrice(cls, api, part, quantity: int, price: float, price_currency: st class PartRelated(inventree.base.InventreeObject): """ Class representing a relationship between parts""" - URL = 'part/related' + URL = 'part/related/' @classmethod def add_related(cls, api, part1, part2): @@ -255,9 +279,16 @@ def add_related(cls, api, part1, part2): return api.post(cls.URL, data) -class Parameter(inventree.base.InventreeObject): - """class representing the Parameter database model """ - URL = 'part/parameter' +class PartParameter(inventree.base.InventreeObject): + """Legacy class representing the PartParameter database model. + + This has now been replaced with the generic Parameter model. + + Ref: https://github.com/inventree/InvenTree/pull/10699 + """ + URL = 'part/parameter/' + + MAX_API_VERSION = 428 def getunits(self): """ Get the units for this parameter """ @@ -265,7 +296,13 @@ def getunits(self): return self._data['template_detail']['units'] -class ParameterTemplate(inventree.base.InventreeObject): - """ class representing the Parameter Template database model""" +class PartParameterTemplate(inventree.base.InventreeObject): + """Legacy class representing the PartParameterTemplate database model. + + This has now been replaced with the generic ParameterTemplate model. + + Ref: https://github.com/inventree/InvenTree/pull/10699 + """ - URL = 'part/parameter/template' + URL = 'part/parameter/template/' + MAX_API_VERSION = 428 diff --git a/inventree/plugin.py b/inventree/plugin.py index 4d692ee4..3626844f 100644 --- a/inventree/plugin.py +++ b/inventree/plugin.py @@ -6,7 +6,7 @@ class InvenTreePlugin(inventree.base.MetadataMixin, inventree.base.InventreeObject): """Represents a PluginConfig instance on the InvenTree server.""" - URL = 'plugins' + URL = 'plugins/' MIN_API_VERSION = 197 @classmethod diff --git a/inventree/purchase_order.py b/inventree/purchase_order.py index ff59f4e8..02c42330 100644 --- a/inventree/purchase_order.py +++ b/inventree/purchase_order.py @@ -10,6 +10,7 @@ class PurchaseOrder( inventree.base.AttachmentMixin, + inventree.base.ParameterMixin, inventree.base.MetadataMixin, inventree.base.StatusMixin, inventree.report.ReportPrintingMixin, @@ -17,7 +18,7 @@ class PurchaseOrder( ): """ Class representing the PurchaseOrder database model """ - URL = 'order/po' + URL = 'order/po/' MODEL_TYPE = 'purchaseorder' def getSupplier(self): @@ -143,7 +144,7 @@ class PurchaseOrderLineItem( ): """ Class representing the PurchaseOrderLineItem database model """ - URL = 'order/po-line' + URL = 'order/po-line/' def getSupplierPart(self): """ @@ -163,7 +164,7 @@ def getOrder(self): """ return PurchaseOrder(self._api, self.order) - def receive(self, quantity=None, status=10, location=None, batch_code='', serial_numbers=''): + def receive(self, quantity=None, status=10, location=None, expiry_date=None, batch_code=None, serial_numbers=None): """ Mark this line item as received. @@ -184,6 +185,7 @@ def receive(self, quantity=None, status=10, location=None, batch_code='', serial location: Location ID, or a StockLocation item If given, the following arguments are also sent as parameters: + expiry_date batch_code serial_numbers """ @@ -201,18 +203,28 @@ def receive(self, quantity=None, status=10, location=None, batch_code='', serial except: # noqa:E722 location_id = int(location) + item_data = { + 'line_item': self.pk, + 'supplier_part': self.part, + 'quantity': quantity, + 'status': status, + 'location': location_id + } + + # Optional fields which may be set + if expiry_date: + item_data['expiry_date'] = expiry_date + + if batch_code: + item_data['batch_code'] = batch_code + + if serial_numbers: + item_data['serial_numbers'] = serial_numbers + # Prepare request data data = { 'items': [ - { - 'line_item': self.pk, - 'supplier_part': self.part, - 'quantity': quantity, - 'status': status, - 'location': location_id, - 'batch_code': batch_code, - 'serial_numbers': serial_numbers - } + item_data, ], 'location': location_id } @@ -236,7 +248,7 @@ class PurchaseOrderExtraLineItem( ): """ Class representing the PurchaseOrderExtraLineItem database model """ - URL = 'order/po-extra-line' + URL = 'order/po-extra-line/' def getOrder(self): """ diff --git a/inventree/report.py b/inventree/report.py index ae28584b..1968e56a 100644 --- a/inventree/report.py +++ b/inventree/report.py @@ -29,7 +29,7 @@ def printReport(self, report, destination=None, *args, **kwargs): If neither plugin nor destination is given, nothing will be done """ - print_url = '/report/print/' + print_url = 'report/print/' template_id = self.getTemplateId(report) response = self._api.post( @@ -124,4 +124,4 @@ def downloadTemplate(self, destination, overwrite=False): class ReportTemplate(ReportFunctions): """Class representing the ReportTemplate model.""" - URL = 'report/template' + URL = 'report/template/' diff --git a/inventree/return_order.py b/inventree/return_order.py index c5cf324f..7534a22d 100644 --- a/inventree/return_order.py +++ b/inventree/return_order.py @@ -11,6 +11,7 @@ class ReturnOrder( inventree.base.AttachmentMixin, + inventree.base.ParameterMixin, inventree.base.MetadataMixin, inventree.base.StatusMixin, inventree.report.ReportPrintingMixin, @@ -18,7 +19,7 @@ class ReturnOrder( ): """Class representing the ReturnOrder database model""" - URL = 'order/ro' + URL = 'order/ro/' MIN_API_VERSION = 104 MODEL_TYPE = 'returnorder' diff --git a/inventree/sales_order.py b/inventree/sales_order.py index b12c99e2..411d3116 100644 --- a/inventree/sales_order.py +++ b/inventree/sales_order.py @@ -11,6 +11,7 @@ class SalesOrder( inventree.base.AttachmentMixin, + inventree.base.ParameterMixin, inventree.base.MetadataMixin, inventree.base.StatusMixin, inventree.report.ReportPrintingMixin, @@ -18,7 +19,7 @@ class SalesOrder( ): """ Class representing the SalesOrder database model """ - URL = 'order/so' + URL = 'order/so/' MODEL_TYPE = 'salesorder' def getCustomer(self): @@ -91,7 +92,7 @@ class SalesOrderLineItem( ): """ Class representing the SalesOrderLineItem database model """ - URL = 'order/so-line' + URL = 'order/so-line/' def getPart(self): """ @@ -182,7 +183,7 @@ class SalesOrderExtraLineItem( ): """ Class representing the SalesOrderExtraLineItem database model """ - URL = 'order/so-extra-line' + URL = 'order/so-extra-line/' def getOrder(self): """ @@ -196,7 +197,8 @@ class SalesOrderAllocation( ): """Class representing the SalesOrderAllocation database model.""" - URL = 'order/so-allocation' + MIN_API_VERSION = 267 + URL = 'order/so-allocation/' def getOrder(self): """Return the SalesOrder to which this SalesOrderAllocation belongs.""" @@ -227,7 +229,7 @@ class SalesOrderShipment( ): """Class representing a shipment for a SalesOrder""" - URL = 'order/so/shipment' + URL = 'order/so/shipment/' def getOrder(self): """Return the SalesOrder to which this SalesOrderShipment belongs.""" @@ -248,7 +250,7 @@ def allocateItems(self, items=[]): """ # Customize URL - url = f'order/so/{self.getOrder().pk}/allocate' + url = f'order/so/{self.getOrder().pk}/allocate/' # Create data from given inputs data = { @@ -275,7 +277,10 @@ def allocations(self): Note: This is an overload of getAllocations() method, for legacy compatibility. """ - return self.getAllocations() + try: + return self.getAllocations() + except NotImplementedError: + return self._data['allocations'] def complete( self, diff --git a/inventree/stock.py b/inventree/stock.py index ad45df75..da87a1ef 100644 --- a/inventree/stock.py +++ b/inventree/stock.py @@ -10,6 +10,8 @@ import inventree.part import inventree.report +logger = logging.getLogger('inventree') + class StockLocation( inventree.base.BarcodeMixin, @@ -20,7 +22,7 @@ class StockLocation( ): """ Class representing the StockLocation database model """ - URL = 'stock/location' + URL = 'stock/location/' MODEL_TYPE = 'stocklocation' def getStockItems(self, **kwargs): @@ -54,10 +56,34 @@ class StockItem( ): """Class representing the StockItem database model.""" - URL = 'stock' - + URL = 'stock/' MODEL_TYPE = 'stockitem' + @classmethod + def create(cls, api, data, **kwargs): + """ Override default create method to support multiple object return. """ + + cls.checkApiVersion(api) + + # Ensure the pk value is None so an existing object is not updated + if cls.getPkField() in data.keys(): + data.pop(cls.getPkField()) + + response = api.post(cls.URL, data, **kwargs) + + if response is None: + logger.error("Error creating new object") + return None + + if isinstance(response, list): + allResponses = [] + for element in response: + allResponses.append(cls(api, data=element)) + return allResponses + + else: + return [cls(api, data=response)] + @classmethod def adjustStockItems(cls, api: inventree.api.InvenTreeAPI, method: str, items: list, **kwargs): """Perform a generic stock 'adjustment' action. @@ -310,7 +336,7 @@ def uploadTestResult(self, test_name, test_result, **kwargs): class StockItemTracking(inventree.base.InventreeObject): """Class representing a StockItem tracking object.""" - URL = 'stock/track' + URL = 'stock/track/' class StockItemTestResult( @@ -328,7 +354,7 @@ class StockItemTestResult( and will be associated with the correct PartTestTemplate on the server. """ - URL = 'stock/test' + URL = 'stock/test/' MODEL_TYPE = 'stockitem' def getTestTemplate(self): diff --git a/inventree/user.py b/inventree/user.py index dc255240..bd358ffa 100644 --- a/inventree/user.py +++ b/inventree/user.py @@ -10,4 +10,16 @@ class User(inventree.base.InventreeObject): """ Class representing the User database model """ - URL = 'user' + URL = 'user/' + + +class Group(inventree.base.InventreeObject): + """Class representing the Group database model""" + + URL = "user/group/" + + +class Owner(inventree.base.InventreeObject): + """Class representing the Owner database model""" + + URL = "user/owner/" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..bf6c42be --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["setuptools >= 75.3.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "inventree" +dynamic = ["version"] +description = "Python interface for InvenTree inventory management system" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.9" +authors = [ + { name = "Oliver Walters", email = "oliver.henry.walters@gmail.com" }, +] +keywords = [ + "barcode", + "bill", + "bom", + "inventory", + "management", + "materials", + "of", + "stock", +] +dependencies = [ + "requests>=2.27.0", + "urllib3>=2.3.0", +] +[project.optional-dependencies] +"system-certs" = ["pip-system-certs>=4.0"] + + +[project.urls] +Homepage = "https://github.com/inventree/inventree-python/" + +[tool.setuptools.dynamic] +version = {attr = "inventree.base.INVENTREE_PYTHON_VERSION"} + +[tool.setuptools] +packages = ["inventree"] + +[tool.flake8] +ignore =[ + 'C901', + # - W293 - blank lines contain whitespace + 'W293', + # - E501 - line too long (82 characters) + 'E501', + 'N802'] +exclude = ['.git','__pycache__','inventree_server','dist','build','test.py'] +max-complexity = 20 + diff --git a/requirements.txt b/requirements.txt index bfc2b0bb..4f1a39de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ requests[socks]>=2.21.0 # Python HTTP for humans with proxy support -flake8==3.8.4 # PEP checking +flake8==7.1.2 # PEP checking +flake8-pyproject==1.2.3 # PEP 621 support wheel>=0.34.2 # Building package invoke>=1.4.0 coverage>=6.4.1 # Run tests, measure coverage diff --git a/setup.cfg b/setup.cfg index d59997bb..e3ab9f28 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,5 +6,5 @@ ignore = # - E501 - line too long (82 characters) E501 N802 -exclude = .git,__pycache__,inventree_server,dist,build,test.py +exclude = .git,.eggs,__pycache__,inventree_server,dist,build,test.py max-complexity = 20 diff --git a/setup.py b/setup.py deleted file mode 100644 index 69442449..00000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- - -import setuptools - -from inventree.base import INVENTREE_PYTHON_VERSION - -with open('README.md', encoding='utf-8') as f: - long_description = f.read() - - -setuptools.setup( - name="inventree", - - version=INVENTREE_PYTHON_VERSION, - - author="Oliver Walters", - - author_email="oliver.henry.walters@gmail.com", - - description="Python interface for InvenTree inventory management system", - - long_description=long_description, - - long_description_content_type='text/markdown', - - keywords="bom, bill of materials, stock, inventory, management, barcode", - - url="https://github.com/inventree/inventree-python/", - - license="MIT", - - packages=setuptools.find_packages( - exclude=[ - 'ci', - 'scripts', - 'test', - ] - ), - - install_requires=[ - "requests>=2.27.0" - ], - - setup_requires=[ - "wheel", - ], - - python_requires=">=3.8" -) diff --git a/tasks.py b/tasks.py index 5733b6c4..5db905d2 100644 --- a/tasks.py +++ b/tasks.py @@ -65,14 +65,15 @@ def check_server(c, host="http://localhost:12345", username="testuser", password auth = HTTPBasicAuth(username=username, password=password) - url = f"{host}/api/user/token/" + token_url = f"{host}/api/user/me/token/" + token_fallback_checked = False response = None while response is None: try: - response = requests.get(url, auth=auth, timeout=0.5) + response = requests.get(token_url, auth=auth, timeout=0.5) except Exception as e: if debug: print("Error:", str(e)) @@ -88,6 +89,16 @@ def check_server(c, host="http://localhost:12345", username="testuser", password else: return False + # Maybe this is an old implementation? Check for X-InvenTree-API header + if response and not token_fallback_checked: + token_version = response.headers.get('X-InvenTree-API', None) + if token_version and int(token_version) < 490: + if debug: + print("No token endpoint, but server is responding - maybe an old implementation?") + token_fallback_checked = True + token_url = f"{host}/api/user/token/" + response = None + if response.status_code != 200: if debug: print(f"Invalid status code: ${response.status_code}") @@ -200,4 +211,5 @@ def test(c, source=None, update=False, reset=False, debug=False, host=None, user c.run(f'coverage run -m unittest {source}') else: # Automatically discover tests, and run only those + print("Running all unit tests") c.run('coverage run -m unittest discover -s test/') diff --git a/test/docker-compose.yml b/test/docker-compose.yml index dba49b31..fa1932ee 100644 --- a/test/docker-compose.yml +++ b/test/docker-compose.yml @@ -1,10 +1,8 @@ -version: "3.8" - # Docker compose recipe for spawning a simple InvenTree server instance, # to use for running local tests of the InvenTree python API # We use the latest (master branch) InvenTree code for testing. -# The tests should be targetted at localhost:12345 +# The tests should be targeted at localhost:12345 services: @@ -16,12 +14,14 @@ services: - 12345:8000 environment: - INVENTREE_DEBUG=True + - INVENTREE_SITE_URL=http://localhost:12345 - INVENTREE_DB_ENGINE=sqlite - INVENTREE_DB_NAME=/home/inventree/data/test_db.sqlite3 - INVENTREE_DEBUG_LEVEL=error - INVENTREE_ADMIN_USER=testuser - INVENTREE_ADMIN_PASSWORD=testpassword - INVENTREE_ADMIN_EMAIL=test@test.com + - INVENTREE_COOKIE_SAMESITE=False restart: unless-stopped volumes: - ./data:/home/inventree/data diff --git a/test/test_api.py b/test/test_api.py index 243a000f..277c5c19 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -57,10 +57,11 @@ def test_url_construction(self): a = api.InvenTreeAPI("http://localhost:1234", connect=False) tests = { - 'part': 'http://localhost:1234/api/part/', - '/part': 'http://localhost:1234/api/part/', - '/part/': 'http://localhost:1234/api/part/', - 'order/so/shipment': 'http://localhost:1234/api/order/so/shipment/', + 'part/': 'http://localhost:1234/api/part/', + '/api/stock/': 'http://localhost:1234/api/stock/', + '/plugin/part/': 'http://localhost:1234/plugin/part/', + 'order/so/shipment': 'http://localhost:1234/api/order/so/shipment', + 'https://example.com/': 'https://example.com/', } for endpoint, url in tests.items(): @@ -209,8 +210,7 @@ def test_create_stuff(self): 'part': p.pk, 'quantity': 45, 'notes': 'This is a note', - - }) + })[0] self.assertIsNotNone(s) self.assertEqual(s.part, p.pk) @@ -273,12 +273,12 @@ def test_add_result(self): 'value': '0x123456', } - result = item.uploadTestResult('firmware', False, **args) + result = item.uploadTestResult('firmwareversion', False, **args) self.assertTrue(result) - item.uploadTestResult('paint', True) - item.uploadTestResult('extra test', False, value='some data') + item.uploadTestResult('temperaturetest', True) + item.uploadTestResult('settingschecksum', False, value='some data') # There should be 3 more test results now! results = item.getTestResults() diff --git a/test/test_build.py b/test/test_build.py index 45929bb8..914a34ad 100644 --- a/test/test_build.py +++ b/test/test_build.py @@ -36,9 +36,9 @@ def get_build(self): self.api, { "title": "Automated test build", - "part": 25, + "part": 100, "quantity": 100, - "reference": f"BO-{n+1:04d}", + "reference": f"BO-{n + 1:04d}", } ) else: @@ -93,7 +93,7 @@ def test_build_cancel(self): "title": "Automated test build", "part": 25, "quantity": 100, - "reference": f"BO-{n+1:04d}" + "reference": f"BO-{n + 1:04d}" } ) @@ -118,7 +118,7 @@ def test_build_complete(self): "title": "Automated test build", "part": 25, "quantity": 100, - "reference": f"BO-{n+1:04d}" + "reference": f"BO-{n + 1:04d}" } ) @@ -144,3 +144,137 @@ def test_build_complete(self): # Check status self.assertEqual(build.status, 40) self.assertEqual(build.status_text, 'Complete') + + +class BuildOrderOutputTests(InvenTreeTestCase): + """ Unit tests for build output functionality """ + + def setUp(self): + """ Ensure we have a base build order to work with """ + + super().setUp() + + builds = Build.list(self.api) + + self.build = Build.create( + self.api, + { + "title": "A new build order", + "part": 25, + "quantity": 10, + "reference": f"BO-{len(builds) + 1:04d}" + } + ) + + def test_create_build_output(self): + """Test that we can create a build output item""" + + # Initially, there should be no build outputs + outputs = self.build.getBuildOutputs() + self.assertEqual(len(outputs), 0) + + # Let's create 3 new outputs (with serial numbers) + outputs = self.build.createBuildOutput( + quantity=3, + batch_code='TEST-BATCH-001', + serial_numbers='400+' + ) + + self.assertEqual(len(outputs), 3) + self.assertEqual(len(self.build.getBuildOutputs()), 3) + + for output in outputs: + self.assertIsNotNone(output) + self.assertEqual(output.quantity, 1) + self.assertEqual(output.batch, 'TEST-BATCH-001') + self.assertEqual(output.build, self.build.pk) + self.assertEqual(output.part, self.build.part) + self.assertTrue(output.is_building) + + # Directly delete the build output + output.delete() + + # There should now be no build outputs again + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + def test_cancel_build_output(self): + """ Test that we can cancel a build output item """ + + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + # Create a new build output + output = self.build.createBuildOutput( + quantity=1, + batch_code='TEST-BATCH-001', + serial_numbers='456' + )[0] + + self.assertEqual(len(self.build.getBuildOutputs()), 1) + + self.build.cancelBuildOutputs(output) + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + def test_complete_build_output(self): + """ Test that we can complete a build output item """ + + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + # Create a new build output + output = self.build.createBuildOutput( + quantity=1, + batch_code='TEST-BATCH-001', + serial_numbers='457' + )[0] + + q = self.build.completed + + self.assertTrue(output.is_building) + self.assertEqual(len(self.build.getBuildOutputs()), 1) + + # Complete the build output + self.build.completeBuildOutput(output, location=1) + + self.assertEqual(len(self.build.getBuildOutputs()), 1) + output.reload() + self.assertFalse(output.is_building) + + # Remove the output + output.delete() + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + # The number of "completed" items should have increased by 1 + self.build.reload() + self.assertEqual(self.build.completed, q + 1) + + def test_scrap_build_output(self): + """Test that we can scrap a build output item""" + + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + # Create a new build output + output = self.build.createBuildOutput( + quantity=1, + batch_code='TEST-BATCH-001', + serial_numbers='468' + )[0] + + q = self.build.completed + + self.assertTrue(output.is_building) + self.assertEqual(len(self.build.getBuildOutputs()), 1) + + # Scrap the build output + self.build.scrapBuildOutput(output, location=1, notes='Test scrap') + self.assertEqual(len(self.build.getBuildOutputs()), 1) + self.assertEqual(len(self.build.getBuildOutputs(complete=False)), 0) + self.assertEqual(len(self.build.getBuildOutputs(complete=True)), 1) + + output.reload() + self.assertFalse(output.is_building) + + # Remove the build output + output.delete() + + # The number of "completed" items should not have increased + self.build.reload() + self.assertEqual(self.build.completed, q) diff --git a/test/test_company.py b/test/test_company.py index c6dd7db6..db8d4a82 100644 --- a/test/test_company.py +++ b/test/test_company.py @@ -3,8 +3,6 @@ import os import sys -from requests.exceptions import HTTPError - try: import Image except ImportError: @@ -51,7 +49,6 @@ def test_fields(self): field_names = company.Company.fieldNames(self.api) for field in [ - 'url', 'name', 'image', 'is_customer', @@ -128,77 +125,6 @@ def test_manufacturer_part_create(self): man_parts = company.ManufacturerPart.list(self.api, manufacturer=manufacturer.pk) self.assertEqual(len(man_parts), n + 1) - def test_manufacturer_part_parameters(self): - """ - Test that we can create, retrieve and edit ManufacturerPartParameter objects - """ - - n = len(company.ManufacturerPart.list(self.api)) - - mpn = f"XYZ-12345678-{n}" - - # First, create a new ManufacturerPart - part = company.ManufacturerPart.create(self.api, { - 'manufacturer': 6, - 'part': 1, - 'MPN': mpn, - }) - - self.assertIsNotNone(part) - self.assertEqual(len(company.ManufacturerPart.list(self.api)), n + 1) - - # Part should (initially) not have any parameters - self.assertEqual(len(part.getParameters()), 0) - - # Now, let's create some! - for idx in range(10): - - parameter = company.ManufacturerPartParameter.create(self.api, { - 'manufacturer_part': part.pk, - 'name': f"param {idx}", - 'value': f"{idx}", - }) - - self.assertIsNotNone(parameter) - - # Now should have 10 unique parameters - self.assertEqual(len(part.getParameters()), 10) - - # Attempt to create a duplicate parameter - with self.assertRaises(HTTPError): - company.ManufacturerPartParameter.create(self.api, { - 'manufacturer_part': part.pk, - 'name': 'param 0', - 'value': 'some value', - }) - - self.assertEqual(len(part.getParameters()), 10) - - # Test that we can edit a ManufacturerPartParameter - parameter = part.getParameters()[0] - - self.assertEqual(parameter.value, '0') - - parameter['value'] = 'new value' - parameter.save() - - self.assertEqual(parameter.value, 'new value') - - parameter['value'] = 'dummy value' - parameter.reload() - - self.assertEqual(parameter.value, 'new value') - - # Test that the "list" function works correctly - results = company.ManufacturerPartParameter.list(self.api) - self.assertGreaterEqual(len(results), 10) - - results = company.ManufacturerPartParameter.list(self.api, name='param 1') - self.assertGreaterEqual(len(results), 1) - - results = company.ManufacturerPartParameter.list(self.api, manufacturer_part=part.pk) - self.assertGreaterEqual(len(results), 10) - def test_supplier_part_create(self): """ Test that we can create SupplierPart objects via the API diff --git a/test/test_label.py b/test/test_label.py index be365b81..1ce02a44 100644 --- a/test/test_label.py +++ b/test/test_label.py @@ -64,11 +64,8 @@ def test_label_print(self): response = part.printLabel(template, plugin=plugin) - for key in ['created', 'model_type', 'complete', 'output', 'template', 'plugin']: + for key in ['created', 'complete', 'output']: self.assertIn(key, response) self.assertEqual(response['complete'], True) - self.assertEqual(response['model_type'], 'part') self.assertIsNotNone(response['output']) - self.assertEqual(response['template'], template.pk) - self.assertEqual(response['plugin'], plugin.key) diff --git a/test/test_order.py b/test/test_order.py index ef552d7b..838fc725 100644 --- a/test/test_order.py +++ b/test/test_order.py @@ -269,11 +269,15 @@ def test_order_complete_with_receive(self): result = po.receiveAll(location=use_location.pk) # Check the result returned - self.assertIsInstance(result, dict) - self.assertIn('items', result) - self.assertIn('location', result) - # Check that all except one line were marked - self.assertEqual(len(result['items']), len(po.getLineItems()) - 1) + if self.api.api_version < 385: # Ref: https://github.com/inventree/InvenTree/pull/10174/ + self.assertIsInstance(result, dict) + self.assertIn('items', result) + self.assertIn('location', result) + # Check that all except one line were marked + self.assertEqual(len(result['items']), len(po.getLineItems()) - 1) + else: + self.assertIsInstance(result, list) + self.assertEqual(len(result), len(po.getLineItems()) - 1) # Receive all line items again - make sure answer is None # use the StockLocation item here diff --git a/test/test_part.py b/test/test_part.py index 8ba0a9fe..4d42a3d4 100644 --- a/test/test_part.py +++ b/test/test_part.py @@ -15,12 +15,12 @@ from test_api import InvenTreeTestCase # noqa: E402 -from inventree.base import Attachment # noqa: E402 +from inventree.base import (Attachment, Parameter, # noqa: E402 + ParameterTemplate) from inventree.company import SupplierPart # noqa: E402 from inventree.part import InternalPrice # noqa: E402 -from inventree.part import (BomItem, Parameter, # noqa: E402 - ParameterTemplate, Part, - PartCategory, PartCategoryParameterTemplate, +from inventree.part import (BomItem, Part, PartCategory, # noqa: E402 + PartCategoryParameterTemplate, PartParameter, PartRelated, PartTestTemplate) from inventree.stock import StockItem # noqa: E402 @@ -28,6 +28,18 @@ class PartCategoryTest(InvenTreeTestCase): """Tests for PartCategory models""" + def test_metadata(self): + """Fetch metadata for a particular category.""" + + cat = PartCategory.list(self.api, limit=1)[0] + + url = cat.metadata_url + + self.assertEqual(url, f"metadata/partcategory/{cat.pk}/") + + metadata = cat.getMetadata() + self.assertIsInstance(metadata, dict) + def test_part_cats(self): """ Tests for category filtering @@ -74,7 +86,7 @@ def test_elec(self): # Create some new categories under this one for idx in range(3): - name = f"Subcategory {n+idx}" + name = f"Subcategory {n + idx}" cat = PartCategory.create(self.api, { "parent": child.pk, @@ -130,6 +142,10 @@ def test_caps(self): def test_part_category_parameter_templates(self): """Unit tests for the PartCategoryParameterTemplate model""" + # Ignore these tests for "legacy" Parameter API + if self.api.api_version < Parameter.MIN_API_VERSION: + return + electronics = PartCategory(self.api, pk=3) # Ensure there are some parameter templates associated with this category @@ -146,7 +162,7 @@ def test_part_category_parameter_templates(self): self.api, data={ 'category': electronics.pk, - 'parameter_template': template.pk, + 'template': template.pk, 'default_value': 123, } ) @@ -186,11 +202,15 @@ def test_part_get_functions(self): 'getBomItems': BomItem, 'isUsedIn': BomItem, 'getStockItems': StockItem, - 'getParameters': Parameter, 'getRelated': PartRelated, 'getInternalPriceList': InternalPrice, } + if self.api.api_version >= Parameter.MIN_API_VERSION: + functions['getParameters'] = Parameter + else: + functions['getParameters'] = PartParameter + if self.api.api_version >= Attachment.MIN_API_VERSION: functions['getAttachments'] = Attachment @@ -282,7 +302,7 @@ def test_part_list(self): for i in range(5): prt = Part.create(self.api, { "category": 5, - "name": f"Special Part {n+i}", + "name": f"Special Part {n + i}", "description": "A new part in this category!", }) @@ -567,9 +587,11 @@ def test_set_price(self): self.assertEqual(ip_price_clean, test_price) def test_parameters(self): - """ - Test setting and getting Part parameter templates, as well as parameter values - """ + """Test setting and getting PartParameterTemplates, as well as PartParameter values.""" + + # Skip test if modernized Parameter API is not supported + if self.api.api_version < Parameter.MIN_API_VERSION: + return # Count number of existing Parameter Templates existingTemplates = len(ParameterTemplate.list(self.api)) @@ -579,7 +601,15 @@ def test_parameters(self): parametertemplate = ParameterTemplate.create(self.api, data={'units': "kg A"}) # Now create a proper parameter template - parametertemplate = ParameterTemplate.create(self.api, data={'name': f'Test parameter no {existingTemplates}', 'units': "kg A"}) + parametertemplate = ParameterTemplate.create( + self.api, + data={ + 'name': f'Test parameter no {existingTemplates}', + 'description': 'A parameter template for testing', + 'model_type': None, + 'units': "kg A" + } + ) # result should not be None self.assertIsNotNone(parametertemplate) @@ -595,17 +625,14 @@ def test_parameters(self): # Define parameter value for this part - without all required values with self.assertRaises(HTTPError): - Parameter.create(self.api, data={'part': p.pk, 'template': parametertemplate.pk}) + Parameter.create(self.api, data={'model_type': 'part', 'model_id': p.pk, 'template': parametertemplate.pk}) # Define parameter value for this part - without all required values with self.assertRaises(HTTPError): - Parameter.create(self.api, data={'part': p.pk, 'data': 10}) + Parameter.create(self.api, data={'model_type': 'part', 'model_id': p.pk, 'data': 10}) # Define w. required values - integer - param = Parameter.create(self.api, data={'part': p.pk, 'template': parametertemplate.pk, 'data': 10}) - - # Unit should be equal - self.assertEqual(param.getunits(), 'kg A') + param = Parameter.create(self.api, data={'model_type': 'part', 'model_id': p.pk, 'template': parametertemplate.pk, 'data': 10}) # result should not be None self.assertIsNotNone(param) @@ -613,7 +640,7 @@ def test_parameters(self): # Same parameter for same part - should fail # Define w. required values - string with self.assertRaises(HTTPError): - Parameter.create(self.api, data={'part': p.pk, 'template': parametertemplate.pk, 'data': 'String value'}) + Parameter.create(self.api, data={'model_type': 'part', 'model_id': p.pk, 'template': parametertemplate.pk, 'data': 'String value'}) # Number of parameters should be one higher than before self.assertEqual(len(p.getParameters()), existingParameters + 1) @@ -703,14 +730,32 @@ def test_get_requirements(self): # Check for expected content self.assertIsInstance(req, dict) - self.assertIn('available_stock', req) - self.assertIn('on_order', req) - self.assertIn('required_build_order_quantity', req) - self.assertIn('allocated_build_order_quantity', req) - self.assertIn('required_sales_order_quantity', req) - self.assertIn('allocated_sales_order_quantity', req) - self.assertIn('allocated', req) - self.assertIn('required', req) + + # Ref: https://github.com/inventree/InvenTree/pull/9798 + if self.api.api_version < 350: + fields = [ + 'on_order', + 'allocated_build_order_quantity', + 'allocated_sales_order_quantity', + 'required_build_order_quantity', + 'required_sales_order_quantity', + ] + else: + fields = [ + 'total_stock', + 'unallocated_stock', + 'can_build', + 'ordering', + 'building', + 'scheduled_to_build', + 'required_for_build_orders', + 'allocated_to_build_orders', + 'required_for_sales_orders', + 'allocated_to_sales_orders', + ] + + for f in fields: + self.assertIn(f, req) class PartBarcodeTest(InvenTreeTestCase): diff --git a/test/test_report.py b/test/test_report.py index 7f309d3f..691eacd9 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -51,9 +51,7 @@ def test_print_report(self): # Print the report response = build.printReport(template) - for key in ['pk', 'model_type', 'output', 'template']: + for key in ['pk', 'output']: self.assertIn(key, response) self.assertIsNotNone(response['output']) - self.assertEqual(response['template'], template.pk) - self.assertEqual(response['model_type'], build.getModelType()) diff --git a/test/test_stock.py b/test/test_stock.py index 25811451..253e0a8a 100644 --- a/test/test_stock.py +++ b/test/test_stock.py @@ -209,9 +209,9 @@ def test_bulk_delete(self): self.assertTrue(len(StockItem.list(self.api, location=3)) >= 10) # Delete *all* items from location 3 - StockItem.bulkDelete(self.api, filters={ - 'location': 3 - }) + items = [item.pk for item in StockItem.list(self.api, location=3)] + + StockItem.bulkDelete(self.api, items=list(items)) loc = StockLocation(self.api, pk=3) items = loc.getStockItems() @@ -248,6 +248,29 @@ def test_barcode_support(self): item.unassignBarcode() + def test_serialized(self): + """Test serializing multiple objects on create""" + + # Create items with serial numbers + items = StockItem.create( + self.api, + { + "part": 10004, + "quantity": 3, + "serial_numbers": "1005,1006,1007" + } + ) + + self.assertEqual(3, len(items)) + + self.assertEqual('1005', items[0].serial) + self.assertEqual('1006', items[1].serial) + self.assertEqual('1007', items[2].serial) + + # Delete the items after the test + for item in items: + item.delete() + class StockAdjustTest(InvenTreeTestCase): """Unit tests for stock 'adjustment' actions""" @@ -407,6 +430,10 @@ def test_assign_stock(self): } ) + # Verify a single result was returned + self.assertEqual(1, len(assignitem)) + assignitem = assignitem[0] + # Assign the item assignitem.assignStock(customer=customer, notes='Sell on the side')