├── .coveragerc ├── .github ├── release.yml └── workflows │ ├── build.yaml │ ├── ci.yaml │ ├── pep.yaml │ └── pypi.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── TESTING.md ├── __init__.py ├── ci └── check_version_number.py ├── inventree ├── __init__.py ├── api.py ├── base.py ├── build.py ├── company.py ├── currency.py ├── label.py ├── order.py ├── part.py ├── plugin.py ├── project_code.py ├── purchase_order.py ├── report.py ├── return_order.py ├── sales_order.py ├── stock.py └── user.py ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── tasks.py └── test ├── __init__.py ├── attachment.txt ├── docker-compose.yml ├── dummytemplate.html ├── dummytemplate2.html ├── test_api.py ├── test_base.py ├── test_build.py ├── test_company.py ├── test_currency.py ├── test_internal_price.py ├── test_label.py ├── test_order.py ├── test_part.py ├── test_plugin.py ├── test_project_codes.py ├── test_report.py └── test_stock.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = ./inventree -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # .github/release.yml 2 | 3 | changelog: 4 | exclude: 5 | labels: 6 | - wontfix 7 | categories: 8 | - title: Breaking Changes 9 | labels: 10 | - Semver-Major 11 | - breaking 12 | - title: New Features 13 | labels: 14 | - Semver-Minor 15 | - enhancement 16 | - title: Bug Fixes 17 | labels: 18 | - Semver-Patch 19 | - bug 20 | - title: Devops / Setup Changes 21 | labels: 22 | - docker 23 | - setup 24 | - demo 25 | - CI 26 | - security 27 | - title: Other Changes 28 | labels: 29 | - "*" 30 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build Package 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | max-parallel: 4 11 | matrix: 12 | python-version: [3.8] 13 | 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install Deps 22 | run: | 23 | pip install -U -r requirements.txt 24 | - name: Build Python Package 25 | run: | 26 | pip install --upgrade pip wheel setuptools build 27 | python3 -m build 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 11 | INVENTREE_SITE_URL: http://localhost:8000 12 | INVENTREE_DB_ENGINE: django.db.backends.sqlite3 13 | INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3 14 | INVENTREE_MEDIA_ROOT: ../test_inventree_media 15 | INVENTREE_STATIC_ROOT: ../test_inventree_static 16 | INVENTREE_BACKUP_DIR: ../test_inventree_backup 17 | INVENTREE_COOKIE_SAMESITE: False 18 | INVENTREE_ADMIN_USER: testuser 19 | INVENTREE_ADMIN_PASSWORD: testpassword 20 | INVENTREE_ADMIN_EMAIL: test@test.com 21 | INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345 22 | INVENTREE_PYTHON_TEST_USERNAME: testuser 23 | INVENTREE_PYTHON_TEST_PASSWORD: testpassword 24 | INVENTREE_DEBUG: True 25 | INVENTREE_LOG_LEVEL: DEBUG 26 | 27 | strategy: 28 | max-parallel: 4 29 | matrix: 30 | python-version: [3.9] 31 | 32 | steps: 33 | - name: Checkout Code 34 | uses: actions/checkout@v2 35 | - name: Set up Python ${{ matrix.python-version }} 36 | uses: actions/setup-python@v2 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | - name: Install Deps 40 | run: | 41 | pip install -U -r requirements.txt 42 | - name: Start InvenTree Server 43 | run: | 44 | sudo apt-get install python3-dev python3-pip python3-venv python3-wheel g++ 45 | pip3 install invoke 46 | git clone --depth 1 https://github.com/inventree/inventree ./inventree_server 47 | cd inventree_server 48 | invoke install 49 | invoke migrate 50 | invoke dev.import-fixtures 51 | invoke dev.server -a 0.0.0.0:12345 & 52 | invoke wait 53 | - name: Run Tests 54 | run: | 55 | invoke check-server -d 56 | coverage run -m unittest discover -s test/ 57 | - name: Upload Report 58 | run: | 59 | coveralls --service=github 60 | -------------------------------------------------------------------------------- /.github/workflows/pep.yaml: -------------------------------------------------------------------------------- 1 | name: Style Checks 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | style: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | max-parallel: 4 11 | matrix: 12 | python-version: [3.8] 13 | 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install Deps 22 | run: | 23 | pip install -U -r requirements.txt 24 | - name: Style Checks 25 | run: | 26 | invoke style 27 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | # Publish to PyPi package index 2 | 3 | name: PIP Publish 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | 11 | publish: 12 | name: Publish to PyPi 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout Code 17 | uses: actions/checkout@v2 18 | - name: Setup Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.8 22 | - name: Check Release Tag 23 | run: | 24 | python3 ci/check_version_number.py ${{ github.event.release.tag_name }} 25 | - name: Install Python Dependencies 26 | run: | 27 | pip install -U -r requirements.txt 28 | pip install --upgrade wheel setuptools twine build 29 | - name: Build Binary 30 | run: | 31 | python3 -m build 32 | - name: Publish 33 | run: | 34 | python3 -m twine upload dist/* 35 | env: 36 | TWINE_USERNAME: __token__ 37 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 38 | TWINE_REPOSITORY: pypi 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't track cred file! 2 | inventree_credentials.py 3 | 4 | # Temporary files 5 | test.py 6 | .coverage 7 | *.tmp.json 8 | *.json.tmp 9 | 10 | dummy_image.* 11 | 12 | test/data/ 13 | test/*.tmp 14 | test/output.png 15 | 16 | # General python / dev stuff 17 | *.pyc 18 | .pypirc 19 | .eggs 20 | .env 21 | dist 22 | build 23 | inventree.egg-info 24 | .vscode/ 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.5.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-yaml 11 | - id: mixed-line-ending 12 | - repo: https://github.com/pycqa/flake8 13 | rev: '6.1.0' 14 | hooks: 15 | - id: flake8 16 | - repo: https://github.com/pycqa/isort 17 | rev: '5.12.0' 18 | hooks: 19 | - id: isort 20 | - repo: https://github.com/codespell-project/codespell 21 | rev: v2.2.6 22 | hooks: 23 | - id: codespell 24 | args: ['-L fo'] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 InvenTree 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 2 | [![PyPI](https://img.shields.io/pypi/v/inventree)](https://pypi.org/project/inventree/) 3 | ![Build Status](https://github.com/inventree/inventree-python/actions/workflows/ci.yaml/badge.svg) 4 | [![Coverage Status](https://coveralls.io/repos/github/inventree/inventree-python/badge.svg)](https://coveralls.io/github/inventree/inventree-python) 5 | ![PEP](https://github.com/inventree/inventree-python/actions/workflows/pep.yaml/badge.svg) 6 | 7 | ## InvenTree Python Interface 8 | 9 | Python library for communication with the [InvenTree parts management system](https:///github.com/inventree/inventree) using the integrated REST API. 10 | 11 | This library provides a class-based interface for interacting with the database. Each database table is represented as a class object which provides features analogous to the REST CRUD endpoints (Create, Retrieve, Update, Destroy). 12 | 13 | ## Installation 14 | 15 | The InvenTree python library can be easily installed using PIP: 16 | 17 | ``` 18 | pip install inventree 19 | ``` 20 | 21 | ## Documentation 22 | 23 | Refer to the [InvenTree documentation](https://docs.inventree.org/en/latest/api/python/python/) 24 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | ## Unit Testing 2 | 3 | The InvenTree python bindings provide a number of unit tests to ensure the code is working correctly. Tests must be run against an instance of the InvenTree web server. 4 | 5 | ### Testing Code 6 | 7 | Unit testing code is located in the `./test/` directory - test files conform to the filename pattern `test_.py`. 8 | 9 | ### Writing Tests 10 | 11 | Any new features should be accompanied by a set of appropriate unit tests, which cover the new features. 12 | 13 | ## Running Tests 14 | 15 | The simplest way to run tests locally is to simply run the following command: 16 | 17 | ``` 18 | invoke test 19 | ``` 20 | 21 | This assumes you have installed, on your path: 22 | 23 | - python (with requirements in requirements.txt) 24 | - invoke 25 | - docker-compose 26 | 27 | Before the first test, run the following: 28 | 29 | ``` 30 | invoke update-image 31 | ``` 32 | 33 | The `invoke test` command performs the following sequence of actions: 34 | 35 | - Ensures the test InvenTree server is running (in a docker container) 36 | - Resets the test database to a known state 37 | - Runs the suite of unit tests 38 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inventree/inventree-python/71d844565503d67ef9475fcd2b77494e325e7272/__init__.py -------------------------------------------------------------------------------- /ci/check_version_number.py: -------------------------------------------------------------------------------- 1 | """ 2 | Before, pypi release, ensure that the release tag matches the inventree version number! 3 | """ 4 | 5 | # -*- coding: utf-8 -*- 6 | from __future__ import unicode_literals 7 | 8 | import argparse 9 | import os 10 | import re 11 | import sys 12 | 13 | if __name__ == '__main__': 14 | 15 | here = os.path.abspath(os.path.dirname(__file__)) 16 | 17 | version_file = os.path.join(here, '..', 'inventree', 'base.py') 18 | 19 | with open(version_file, 'r') as f: 20 | 21 | results = re.findall(r'INVENTREE_PYTHON_VERSION = "(.*)"', f.read()) 22 | 23 | if not len(results) == 1: 24 | print(f"Could not find INVENTREE_SW_VERSION in {version_file}") 25 | sys.exit(1) 26 | 27 | version = results[0] 28 | 29 | parser = argparse.ArgumentParser() 30 | parser.add_argument('tag', help='Version tag', action='store') 31 | 32 | args = parser.parse_args() 33 | 34 | if not args.tag == version: 35 | print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'") 36 | sys.exit(1) 37 | 38 | sys.exit(0) 39 | -------------------------------------------------------------------------------- /inventree/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inventree/inventree-python/71d844565503d67ef9475fcd2b77494e325e7272/inventree/__init__.py -------------------------------------------------------------------------------- /inventree/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | The inventree_api module handles low level requests and authentication 5 | with the InvenTree database server. 6 | """ 7 | 8 | import json 9 | import logging 10 | import os 11 | from urllib.parse import urljoin, urlparse 12 | 13 | import requests 14 | from requests.auth import HTTPBasicAuth 15 | from requests.exceptions import Timeout 16 | 17 | logger = logging.getLogger('inventree') 18 | 19 | 20 | class InvenTreeAPI(object): 21 | """ 22 | Basic class for performing Inventree API requests. 23 | """ 24 | 25 | MIN_SUPPORTED_API_VERSION = 206 26 | 27 | @staticmethod 28 | def getMinApiVersion(): 29 | """ 30 | Return the minimum supported API version 31 | """ 32 | 33 | return InvenTreeAPI.MIN_SUPPORTED_API_VERSION 34 | 35 | def __init__(self, host=None, **kwargs): 36 | """ Initialize class with initial parameters 37 | 38 | Args: 39 | base_url - Base URL for the InvenTree server, including port (if required) 40 | e.g. "http://inventree.server.com:8000" 41 | 42 | kwargs: 43 | username - Login username 44 | password - Login password 45 | token - Authentication token (if provided, username/password are ignored) 46 | token-name - Name of the token to use (default = 'inventree-python-client') 47 | use_token_auth - Use token authentication? (default = True) 48 | verbose - Print extra debug messages (default = False) 49 | strict - Enforce strict HTTPS certificate checking (default = True) 50 | timeout - Set timeout to use (in seconds). Default: 10 51 | proxies - Definition of proxies as a dict (defaults to an empty dict) 52 | 53 | Login details can be specified using environment variables, rather than being provided as arguments: 54 | INVENTREE_API_HOST - Host address e.g. "http://inventree.server.com:8000" 55 | INVENTREE_API_USERNAME - Username 56 | INVENTREE_API_PASSWORD - Password 57 | INVENTREE_API_TOKEN - User access token 58 | INVENTREE_API_TIMEOUT - Timeout value, in seconds 59 | """ 60 | 61 | self.setHostName(host or os.environ.get('INVENTREE_API_HOST', None)) 62 | 63 | # Check for environment variables 64 | self.username = kwargs.get('username', os.environ.get('INVENTREE_API_USERNAME', None)) 65 | self.password = kwargs.get('password', os.environ.get('INVENTREE_API_PASSWORD', None)) 66 | self.token = kwargs.get('token', os.environ.get('INVENTREE_API_TOKEN', None)) 67 | self.token_name = kwargs.get('token_name', os.environ.get('INVENTREE_API_TOKEN_NAME', 'inventree-python-client')) 68 | self.timeout = kwargs.get('timeout', os.environ.get('INVENTREE_API_TIMEOUT', 10)) 69 | self.proxies = kwargs.get('proxies', dict()) 70 | self.strict = bool(kwargs.get('strict', True)) 71 | 72 | self.use_token_auth = kwargs.get('use_token_auth', True) 73 | self.verbose = kwargs.get('verbose', False) 74 | 75 | self.auth = None 76 | self.connected = False 77 | 78 | if kwargs.get('connect', True): 79 | self.connect() 80 | 81 | def setHostName(self, host): 82 | """Validate that the provided base URL is valid""" 83 | 84 | if host is None: 85 | raise AttributeError("InvenTreeAPI initialized without providing host address") 86 | 87 | # Ensure that the provided URL is valid 88 | url = urlparse(host) 89 | 90 | if not url.scheme: 91 | raise Exception(f"Host '{host}' supplied without valid scheme") 92 | 93 | if not url.netloc or not url.hostname: 94 | raise Exception(f"Host '{host}' supplied without valid hostname") 95 | 96 | # Check if the path is provided with '/api/' at the end 97 | ps = [el for el in url.path.split('/') if len(el) > 0] 98 | 99 | if len(ps) > 0 and ps[-1] == 'api': 100 | ps = ps[:-1] 101 | 102 | path = '/'.join(ps) 103 | 104 | # Re-construct the URL as required 105 | self.base_url = f"{url.scheme}://{url.netloc}/{path}" 106 | 107 | if not self.base_url.endswith('/'): 108 | self.base_url += '/' 109 | 110 | # Re-construct the API URL as required 111 | self.api_url = urljoin(self.base_url, 'api/') 112 | 113 | def connect(self): 114 | """Attempt a connection to the server""" 115 | 116 | logger.info(f"Connecting to server: {self.base_url}") 117 | 118 | self.connected = False 119 | 120 | # Check if the server is there 121 | try: 122 | self.connected = self.testServer() 123 | except Timeout as e: 124 | # Send the Timeout error further 125 | raise e 126 | except Exception: 127 | raise ConnectionRefusedError("Could not connect to InvenTree server") 128 | 129 | # Basic authentication 130 | self.auth = HTTPBasicAuth(self.username, self.password) 131 | 132 | if not self.testAuth(): 133 | raise ConnectionError("Authentication at InvenTree server failed") 134 | 135 | if self.use_token_auth: 136 | if not self.token: 137 | self.requestToken() 138 | 139 | def constructApiUrl(self, endpoint_url): 140 | """Construct an API endpoint URL based on the provided API URL. 141 | 142 | Arguments: 143 | endpoint_url: The particular API endpoint (everything after "/api/") 144 | 145 | Returns: A fully qualified URL for the subsequent request 146 | """ 147 | 148 | # Strip leading / character if provided 149 | if endpoint_url.startswith("/"): 150 | endpoint_url = endpoint_url[1:] 151 | 152 | url = urljoin(self.api_url, endpoint_url) 153 | 154 | # Ensure the API URL ends with a trailing slash 155 | if not url.endswith('/'): 156 | url += '/' 157 | 158 | return url 159 | 160 | def testAuth(self): 161 | """ 162 | Checks if the set user credentials or the used token 163 | are valid and raises an exception if not. 164 | """ 165 | logger.info("Checking InvenTree user credentials") 166 | 167 | if not self.connected: 168 | logger.fatal("InvenTree server is not connected. Skipping authentication check") 169 | return False 170 | 171 | try: 172 | response = self.get('/user/me/') 173 | except requests.exceptions.HTTPError as e: 174 | logger.fatal(f"Authentication error: {str(type(e))}") 175 | return False 176 | except Exception as e: 177 | logger.fatal(f"Unhandled server error: {str(type(e))}") 178 | # Re-throw the exception 179 | raise e 180 | 181 | # set user_name if not initially set 182 | if not self.username: 183 | self.username = response['username'] 184 | 185 | return True 186 | 187 | def testServer(self): 188 | """ 189 | Check to see if the server is present. 190 | The InvenTree server provides a simple endpoint at /api/ 191 | which contains some simple data (and does not require authentication) 192 | """ 193 | 194 | self.server_details = None 195 | 196 | logger.info("Checking InvenTree server connection...") 197 | 198 | try: 199 | response = requests.get( 200 | self.api_url, 201 | timeout=self.timeout, 202 | proxies=self.proxies, 203 | verify=self.strict 204 | ) 205 | except requests.exceptions.ConnectionError as e: 206 | logger.fatal(f"Server connection error: {str(type(e))}") 207 | return False 208 | except Exception as e: 209 | logger.fatal(f"Unhandled server error: {str(type(e))}") 210 | # Re-throw the exception 211 | raise e 212 | 213 | if response.status_code != 200: 214 | raise requests.exceptions.RequestException(f"Error code from server: {response.status_code} - {response.text}") 215 | 216 | # Record server details 217 | self.server_details = json.loads(response.text) 218 | 219 | logger.info(f"InvenTree server details: {response.text}") 220 | 221 | # The details provided by the server should include some specific data: 222 | server_name = str(self.server_details.get('server', '')) 223 | 224 | if not server_name.lower() == 'inventree': 225 | logger.warning(f"Server returned strange response (expected 'InvenTree', found '{server_name}')") 226 | 227 | api_version = self.server_details.get('apiVersion', '1') 228 | 229 | try: 230 | api_version = int(api_version) 231 | except ValueError: 232 | raise ValueError(f"Server returned invalid API version: '{api_version}'") 233 | 234 | if api_version < InvenTreeAPI.getMinApiVersion(): 235 | raise ValueError(f"Server API version ({api_version}) is older than minimum supported API version ({InvenTreeAPI.getMinApiVersion()})") 236 | 237 | # Store the server API version 238 | self.api_version = api_version 239 | 240 | return True 241 | 242 | def requestToken(self): 243 | """ Return authentication token from the server """ 244 | 245 | if not self.username or not self.password: 246 | raise AttributeError('Supply username and password to request token') 247 | 248 | logger.info("Requesting auth token from server...") 249 | 250 | if not self.connected: 251 | logger.fatal("InvenTree server is not connected. Skipping token request") 252 | return False 253 | 254 | # Request an auth token from the server 255 | try: 256 | response = self.get( 257 | '/user/token/', 258 | params={ 259 | 'name': self.token_name, 260 | } 261 | ) 262 | except Exception as e: 263 | logger.error(f"Error requesting token: {str(type(e))}") 264 | return None 265 | 266 | if 'token' not in response: 267 | logger.error(f"Token not returned by server: {response}") 268 | return None 269 | 270 | self.token = response['token'] 271 | 272 | logger.info(f"Authentication token: {self.token}") 273 | 274 | return self.token 275 | 276 | def request(self, api_url, **kwargs): 277 | """ Perform a URL request to the Inventree API """ 278 | 279 | if not self.connected: 280 | # If we have not established a connection to the server yet, attempt now 281 | self.connect() 282 | 283 | api_url = self.constructApiUrl(api_url) 284 | 285 | data = kwargs.get('data', kwargs.get('json', {})) 286 | files = kwargs.get('files', {}) 287 | params = kwargs.get('params', {}) 288 | headers = kwargs.get('headers', {}) 289 | proxies = kwargs.get('proxies', self.proxies) 290 | 291 | search_term = kwargs.pop('search', None) 292 | 293 | if search_term is not None: 294 | params['search'] = search_term 295 | 296 | # Use provided HTTP method 297 | method = kwargs.get('method', 'get') 298 | 299 | methods = { 300 | 'GET': requests.get, 301 | 'POST': requests.post, 302 | 'PUT': requests.put, 303 | 'PATCH': requests.patch, 304 | 'DELETE': requests.delete, 305 | 'OPTIONS': requests.options, 306 | } 307 | 308 | if method.upper() not in methods.keys(): 309 | logger.error(f"Unknown request method '{method}'") 310 | return None 311 | 312 | method = method.upper() 313 | 314 | payload = { 315 | 'params': params, 316 | 'timeout': kwargs.get('timeout', self.timeout), 317 | } 318 | 319 | if self.use_token_auth and self.token: 320 | headers['AUTHORIZATION'] = f'Token {self.token}' 321 | auth = None 322 | else: 323 | auth = self.auth 324 | 325 | payload['headers'] = headers 326 | payload['auth'] = auth 327 | payload['proxies'] = proxies 328 | 329 | # If we are providing files, we cannot upload as a 'json' request 330 | if files: 331 | payload['data'] = data 332 | payload['files'] = files 333 | else: 334 | payload['json'] = data 335 | 336 | # Enforce strict HTTPS certificate checking? 337 | payload['verify'] = self.strict 338 | 339 | # Debug request information 340 | logger.debug("Sending Request:") 341 | logger.debug(f" - URL: {method} {api_url}") 342 | 343 | for item, value in payload.items(): 344 | logger.debug(f" - {item}: {value}") 345 | 346 | # Send request to server! 347 | try: 348 | response = methods[method](api_url, **payload) 349 | except Timeout as e: 350 | # Re-throw Timeout, and add a message to the log 351 | logger.critical(f"Server timed out during api.request - {method} @ {api_url}. Timeout {payload['timeout']} s.") 352 | raise e 353 | except Exception as e: 354 | # Re-thrown any caught errors, and add a message to the log 355 | logger.critical(f"Error at api.request - {method} @ {api_url}") 356 | raise e 357 | 358 | if response is None: 359 | logger.error(f"Null response - {method} '{api_url}'") 360 | return None 361 | 362 | logger.info(f"Request: {method} {api_url} - {response.status_code}") 363 | 364 | # Detect invalid response codes 365 | # Anything 300+ is 'bad' 366 | if response.status_code >= 300: 367 | 368 | detail = { 369 | 'detail': 'Error occurred during API request', 370 | 'url': api_url, 371 | 'method': method, 372 | 'status_code': response.status_code, 373 | 'body': response.text, 374 | } 375 | 376 | if headers: 377 | detail['headers'] = headers 378 | 379 | if params: 380 | detail['params'] = params 381 | 382 | if files: 383 | detail['files'] = files 384 | 385 | if data: 386 | detail['data'] = data 387 | 388 | raise requests.exceptions.HTTPError(detail) 389 | 390 | # A delete request won't return JSON formatted data (ignore further checks) 391 | if method == 'DELETE': 392 | return response 393 | 394 | ctype = response.headers.get('content-type') 395 | 396 | # An API request must respond in JSON format 397 | if not ctype == 'application/json': 398 | raise requests.exceptions.InvalidJSONError( 399 | f"'Response content-type is not JSON - '{api_url}' - '{ctype}'" 400 | ) 401 | 402 | return response 403 | 404 | def delete(self, url, **kwargs): 405 | """ Perform a DELETE request. Used to remove a record in the database. 406 | 407 | """ 408 | 409 | headers = kwargs.get('headers', {}) 410 | 411 | response = self.request(url, method='delete', headers=headers, **kwargs) 412 | 413 | if response is None: 414 | return None 415 | 416 | if response.status_code not in [204]: 417 | logger.error(f"DELETE request failed at '{url}' - {response.status_code}") 418 | 419 | logger.debug(f"DELETE request at '{url}' returned: {response.status_code} {response.text}") 420 | 421 | return response 422 | 423 | def post(self, url, data, **kwargs): 424 | """ Perform a POST request. Used to create a new record in the database. 425 | 426 | Args: 427 | url - API endpoint URL 428 | data - JSON data to create new object 429 | files - Dict of file attachments 430 | """ 431 | 432 | params = { 433 | 'format': kwargs.pop('format', 'json') 434 | } 435 | 436 | response = self.request( 437 | url, 438 | json=data, 439 | method='post', 440 | params=params, 441 | **kwargs 442 | ) 443 | 444 | if response is None: 445 | logger.error(f"PATCH returned null response at '{url}'") 446 | return None 447 | 448 | if response.status_code not in [200, 201]: 449 | logger.error(f"PATCH request failed at '{url}' - {response.status_code}") 450 | return None 451 | 452 | try: 453 | data = json.loads(response.text) 454 | except json.decoder.JSONDecodeError: 455 | logger.error(f"Error decoding JSON response - '{url}'") 456 | return None 457 | 458 | return data 459 | 460 | def patch(self, url, data, **kwargs): 461 | """ 462 | Perform a PATCH request. 463 | 464 | Args: 465 | url - API endpoint URL 466 | data - JSON data 467 | files - optional FILES struct 468 | """ 469 | 470 | params = { 471 | 'format': kwargs.pop('format', 'json') 472 | } 473 | 474 | response = self.request( 475 | url, 476 | json=data, 477 | method='patch', 478 | params=params, 479 | **kwargs 480 | ) 481 | 482 | if response is None: 483 | logger.error(f"PATCH returned null response at '{url}'") 484 | return None 485 | 486 | if response.status_code not in [200, 201]: 487 | logger.error(f"PATCH request failed at '{url}' - {response.status_code}") 488 | return None 489 | 490 | try: 491 | data = json.loads(response.text) 492 | except json.decoder.JSONDecodeError: 493 | logger.error(f"Error decoding JSON response - '{url}'") 494 | return None 495 | 496 | return data 497 | 498 | def put(self, url, data, **kwargs): 499 | """ 500 | Perform a PUT request. Used to update existing records in the database. 501 | 502 | Args: 503 | url - API endpoint URL 504 | data - JSON data to PUT 505 | """ 506 | 507 | params = { 508 | 'format': kwargs.pop('format', 'json') 509 | } 510 | 511 | response = self.request( 512 | url, 513 | json=data, 514 | method='put', 515 | params=params, 516 | **kwargs 517 | ) 518 | 519 | if response is None: 520 | return None 521 | 522 | if response.status_code not in [200, 201]: 523 | logger.error(f"PUT request failed at '{url}' - {response.status_code}") 524 | return None 525 | 526 | try: 527 | data = json.loads(response.text) 528 | except json.decoder.JSONDecodeError: 529 | logger.error(f"Error decoding JSON response - '{url}'") 530 | return None 531 | 532 | return data 533 | 534 | def get(self, url, **kwargs): 535 | """ Perform a GET request. 536 | 537 | For argument information, refer to the 'request' method 538 | """ 539 | 540 | response = self.request(url, method='get', **kwargs) 541 | 542 | # No response returned 543 | if response is None: 544 | return None 545 | 546 | try: 547 | data = json.loads(response.text) 548 | except json.decoder.JSONDecodeError: 549 | logger.error(f"Error decoding JSON response - '{url}'") 550 | return None 551 | 552 | return data 553 | 554 | def downloadFile(self, url, destination, overwrite=False, params=None, proxies=dict()): 555 | """ 556 | Download a file from the InvenTree server. 557 | 558 | Args: 559 | destination: Filename (string) 560 | 561 | - If the "destination" is a directory, use the filename of the remote URL 562 | """ 563 | 564 | if url.startswith('/'): 565 | url = url[1:] 566 | 567 | fullurl = urljoin(self.base_url, url) 568 | 569 | if os.path.exists(destination) and os.path.isdir(destination): 570 | 571 | destination = os.path.join( 572 | destination, 573 | os.path.basename(fullurl) 574 | ) 575 | 576 | destination = os.path.abspath(destination) 577 | 578 | if os.path.exists(destination) and not overwrite: 579 | raise FileExistsError(f"Destination file '{destination}' already exists") 580 | 581 | if self.token: 582 | headers = { 583 | 'AUTHORIZATION': f"Token {self.token}" 584 | } 585 | auth = None 586 | else: 587 | headers = {} 588 | auth = self.auth 589 | 590 | with requests.get( 591 | fullurl, 592 | stream=True, 593 | auth=auth, 594 | headers=headers, 595 | params=params, 596 | timeout=self.timeout, 597 | proxies=self.proxies, 598 | verify=self.strict, 599 | ) as response: 600 | 601 | # Error code 602 | if response.status_code >= 300: 603 | detail = { 604 | 'detail': 'Error occurred during file download', 605 | 'url': url, 606 | 'status_code': response.status_code, 607 | 'body': response.text 608 | } 609 | 610 | if headers: 611 | detail['headers'] = headers 612 | 613 | raise requests.exceptions.HTTPError(detail) 614 | 615 | headers = response.headers 616 | 617 | if not url.startswith('media/report') and not url.startswith('media/label') and 'Content-Type' in headers and 'text/html' in headers['Content-Type']: 618 | logger.error(f"Error downloading file '{url}': Server return invalid response (text/html)") 619 | return False 620 | 621 | with open(destination, 'wb') as f: 622 | 623 | for chunk in response.iter_content(chunk_size=16 * 1024): 624 | f.write(chunk) 625 | 626 | logger.info(f"Downloaded '{url}' to '{destination}'") 627 | return True 628 | 629 | def scanBarcode(self, barcode_data): 630 | """Scan a barcode to see if it matches a known object""" 631 | 632 | if type(barcode_data) is dict: 633 | barcode_data = json.dumps(barcode_data) 634 | 635 | response = self.post( 636 | '/barcode/', 637 | { 638 | 'barcode': str(barcode_data), 639 | } 640 | ) 641 | 642 | return response 643 | -------------------------------------------------------------------------------- /inventree/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import logging 5 | import requests 6 | import os 7 | 8 | from . import api as inventree_api 9 | 10 | INVENTREE_PYTHON_VERSION = "0.17.5" 11 | 12 | 13 | logger = logging.getLogger('inventree') 14 | 15 | 16 | class InventreeObject(object): 17 | """ Base class for an InvenTree object """ 18 | 19 | # API URL (required) for the particular model type 20 | URL = "" 21 | 22 | @classmethod 23 | def get_url(cls, api): 24 | """Helper method to get the URL associated with this model.""" 25 | return cls.URL 26 | 27 | # Minimum server version for the particular model type 28 | MIN_API_VERSION = None 29 | MAX_API_VERSION = None 30 | 31 | MODEL_TYPE = None 32 | 33 | @classmethod 34 | def getPkField(cls): 35 | """Return the primary key field name for this model. 36 | 37 | The default value (used for most models) is 'pk'. 38 | """ 39 | return 'pk' 40 | 41 | def getPkValue(self): 42 | """Return the primary key value for this model.""" 43 | 44 | return self._data.get(self.getPkField(), None) 45 | 46 | @property 47 | def pk(self): 48 | """Override the 'pk' property to return the primary key value for this object. 49 | 50 | Note that by default this is the 'pk' field, but can be overridden in subclasses. 51 | """ 52 | val = self.getPkValue() 53 | 54 | # Coerce 'pk' values to integer 55 | if self.getPkField() == 'pk': 56 | val = int(val) 57 | 58 | return val 59 | 60 | def __str__(self): 61 | """ 62 | Simple human-readable printing. 63 | Can override in subclass 64 | """ 65 | 66 | return f"{type(self)}<{self.getPkField()}={self.pk}>" 67 | 68 | def __init__(self, api, pk=None, data=None): 69 | """ Instantiate this InvenTree object. 70 | 71 | Args: 72 | api - The request manager object 73 | pk - The ID (primary key) associated with this object on the server 74 | data - JSON representation of the object 75 | """ 76 | 77 | self.checkApiVersion(api) 78 | 79 | # If the pk is not explicitly provided, 80 | # extract it from the provided dataset 81 | if pk is None and data: 82 | pk = data.get(self.getPkField(), None) 83 | 84 | if self.getPkField() == 'pk' and pk is not None: 85 | try: 86 | pk = int(str(pk).strip()) 87 | except Exception: 88 | raise TypeError(f"Invalid primary key value '{pk}' for {self.__class__}") 89 | 90 | if pk <= 0: 91 | raise ValueError(f"Supplier value ({pk}) for {self.__class__} must be positive.") 92 | 93 | url = self.get_url(api) 94 | 95 | self._url = f"{url}/{pk}/" 96 | self._api = api 97 | 98 | if data is None: 99 | data = {} 100 | 101 | self._data = data 102 | 103 | # If the data are not populated, fetch from server 104 | if len(self._data) == 0: 105 | self.reload() 106 | 107 | @classmethod 108 | def getModelType(cls): 109 | """Return the model type for this label printing class.""" 110 | return cls.MODEL_TYPE 111 | 112 | @classmethod 113 | def checkApiVersion(cls, api): 114 | """Check if the API version supports this particular model. 115 | 116 | Raises: 117 | NotSupportedError if the server API version is too 'old' 118 | """ 119 | 120 | if cls.MIN_API_VERSION and cls.MIN_API_VERSION > api.api_version: 121 | raise NotImplementedError(f"Server API Version ({api.api_version}) is too old for the '{cls.__name__}' class, which requires API version >= {cls.MIN_API_VERSION}") 122 | 123 | if cls.MAX_API_VERSION and cls.MAX_API_VERSION < api.api_version: 124 | raise NotImplementedError(f"Server API Version ({api.api_version}) is too new for the '{cls.__name__}' class, which requires API version <= {cls.MAX_API_VERSION}") 125 | 126 | @classmethod 127 | def options(cls, api): 128 | """Perform an OPTIONS request for this model, to determine model information. 129 | 130 | InvenTree provides custom metadata for each API endpoint, accessed via a HTTP OPTIONS request. 131 | This endpoint provides information on the various fields available for that endpoint. 132 | """ 133 | 134 | cls.checkApiVersion(api) 135 | 136 | response = api.request( 137 | cls.URL, 138 | method='OPTIONS', 139 | ) 140 | 141 | if not response.status_code == 200: 142 | logger.error(f"OPTIONS for '{cls.URL}' returned code {response.status_code}") 143 | return {} 144 | 145 | try: 146 | data = json.loads(response.text) 147 | except json.decoder.JSONDecodeError: 148 | logger.error(f"Error decoding OPTIONS response for '{cls.URL}'") 149 | return {} 150 | 151 | return data 152 | 153 | @classmethod 154 | def fields(cls, api): 155 | """ 156 | Returns a list of available fields for this model. 157 | 158 | Introspects the available fields using an OPTIONS request. 159 | """ 160 | 161 | opts = cls.options(api) 162 | 163 | actions = opts.get('actions', {}) 164 | post = actions.get('POST', {}) 165 | 166 | return post 167 | 168 | @classmethod 169 | def fieldInfo(cls, field_name, api): 170 | """Return metadata for a specific field on a model""" 171 | 172 | fields = cls.fields(api) 173 | 174 | if field_name in fields: 175 | return fields[field_name] 176 | else: 177 | logger.warning(f"Field '{field_name}' not found in OPTIONS request for {cls.URL}") 178 | return {} 179 | 180 | @classmethod 181 | def fieldNames(cls, api): 182 | """ 183 | Return a list of available field names for this model 184 | """ 185 | 186 | return [k for k in cls.fields(api).keys()] 187 | 188 | @classmethod 189 | def create(cls, api, data, **kwargs): 190 | """ Create a new database object in this class. """ 191 | 192 | cls.checkApiVersion(api) 193 | 194 | # Ensure the pk value is None so an existing object is not updated 195 | if cls.getPkField() in data.keys(): 196 | data.pop(cls.getPkField()) 197 | 198 | response = api.post(cls.URL, data, **kwargs) 199 | 200 | if response is None: 201 | logger.error("Error creating new object") 202 | return None 203 | 204 | return cls(api, data=response) 205 | 206 | @classmethod 207 | def count(cls, api, **kwargs): 208 | """Return a count of all items of this class in the database""" 209 | 210 | params = kwargs 211 | 212 | # By limiting to a single result, we perform a fast query, but get a total number of results 213 | params['limit'] = 1 214 | 215 | response = api.get(url=cls.URL, params=params) 216 | 217 | return response['count'] 218 | 219 | @classmethod 220 | def list(cls, api, **kwargs): 221 | """Return a list of all items in this class on the database. 222 | 223 | Requires: 224 | 225 | URL - Base URL 226 | """ 227 | cls.checkApiVersion(api) 228 | 229 | # Check if custom URL is present in request arguments 230 | if 'url' in kwargs: 231 | url = kwargs.pop('url') 232 | else: 233 | url = cls.URL 234 | 235 | try: 236 | response = api.get(url=url, params=kwargs) 237 | except requests.exceptions.HTTPError as e: 238 | logger.error(f"Error during list request: {e}") 239 | # Return an empty list 240 | 241 | raise_error = kwargs.get('raise_error', False) 242 | 243 | if raise_error: 244 | raise e 245 | else: 246 | return [] 247 | 248 | if response is None: 249 | return [] 250 | 251 | items = [] 252 | 253 | if isinstance(response, dict) and response['results'] is not None: 254 | response = response['results'] 255 | 256 | for data in response: 257 | if cls.getPkField() in data: 258 | items.append(cls(data=data, api=api)) 259 | 260 | return items 261 | 262 | def delete(self): 263 | """ Delete this object from the database """ 264 | 265 | self.checkApiVersion(self._api) 266 | 267 | if self._api: 268 | return self._api.delete(self._url) 269 | 270 | def save(self, data=None, files=None, method='PATCH'): 271 | """ 272 | Save this object to the database 273 | """ 274 | 275 | self.checkApiVersion(self._api) 276 | 277 | # If 'data' is not specified, then use *all* the data 278 | if data is None: 279 | data = self._data 280 | 281 | if self._api: 282 | 283 | # Default method used is PATCH (partial update) 284 | if method.lower() == 'patch': 285 | response = self._api.patch(self._url, data, files=files) 286 | elif method.lower() == 'put': 287 | response = self._api.put(self._url, data, files=files) 288 | else: 289 | logger.warning(f"save() called with unknown method '{method}'") 290 | return 291 | 292 | # Automatically re-load data from the returned data 293 | if response is not None: 294 | self._data = response 295 | else: 296 | self.reload() 297 | 298 | return response 299 | 300 | def is_valid(self): 301 | """ 302 | Test if this object is 'valid' - it has received data from the server. 303 | 304 | To be considered 'valid': 305 | 306 | - Must have a non-null PK 307 | - Must have a non-null and non-empty data structure 308 | """ 309 | 310 | data = getattr(self, '_data', None) 311 | 312 | if self.pk is None: 313 | return False 314 | 315 | if data is None: 316 | return False 317 | 318 | if len(data) == 0: 319 | return False 320 | 321 | return True 322 | 323 | def reload(self): 324 | """ Reload object data from the database """ 325 | 326 | self.checkApiVersion(self._api) 327 | 328 | if self._api: 329 | data = self._api.get(self._url) 330 | 331 | if data is None: 332 | logger.error(f"Error during reload at {self._url}") 333 | else: 334 | self._data = data 335 | 336 | if not self.is_valid(): 337 | logger.error(f"Error during reload at {self._url} - returned data is invalid") 338 | 339 | else: 340 | raise AttributeError(f"model.reload failed at '{self._url}': No API instance provided") 341 | 342 | def keys(self): 343 | return self._data.keys() 344 | 345 | def __contains__(self, name): 346 | return name in self._data 347 | 348 | def __getattr__(self, name): 349 | 350 | if name in self._data.keys(): 351 | return self._data[name] 352 | else: 353 | return super().__getattribute__(name) 354 | 355 | def __getitem__(self, name): 356 | if name in self._data.keys(): 357 | return self._data[name] 358 | else: 359 | raise KeyError(f"Key '{name}' does not exist in dataset") 360 | 361 | def __setitem__(self, name, value): 362 | if name in self._data.keys(): 363 | self._data[name] = value 364 | else: 365 | raise KeyError(f"Key '{name}' does not exist in dataset") 366 | 367 | 368 | class BulkDeleteMixin: 369 | """Mixin class for models which support 'bulk deletion' 370 | 371 | - Perform a DELETE operation against the LIST endpoint for the model 372 | - Provide a list of items to be deleted, or filters to apply 373 | 374 | Requires API version 58 375 | """ 376 | 377 | @classmethod 378 | def bulkDelete(cls, api: inventree_api.InvenTreeAPI, items=None, filters=None): 379 | """Perform bulk delete operation 380 | 381 | Arguments: 382 | api: InventreeAPI instance 383 | items: Optional list of items (pk values) to be deleted 384 | filters: Optional query filters to delete 385 | 386 | Returns: 387 | API response object 388 | 389 | Throws: 390 | NotImplementError: The server API version is too old (requires v58) 391 | ValueError: Neither items or filters are supplied 392 | 393 | """ 394 | 395 | if not items and not filters: 396 | raise ValueError("Must supply either 'items' or 'filters' argument") 397 | 398 | data = {} 399 | 400 | if items: 401 | data['items'] = items 402 | 403 | if filters: 404 | data['filters'] = filters 405 | 406 | return api.delete( 407 | cls.URL, 408 | json=data, 409 | ) 410 | 411 | 412 | class Attachment(BulkDeleteMixin, InventreeObject): 413 | """Class representing a file attachment object.""" 414 | 415 | URL = 'attachment/' 416 | 417 | # Ref: https://github.com/inventree/InvenTree/pull/7420 418 | MIN_API_VERSION = 207 419 | 420 | @classmethod 421 | def add_link(cls, api, link, comment="", **kwargs): 422 | """ 423 | Add an external link attachment. 424 | 425 | Args: 426 | api: Authenticated InvenTree API instance 427 | link: External link to attach 428 | comment: Add comment to the attachment 429 | kwargs: Additional kwargs to suppl 430 | """ 431 | 432 | data = kwargs 433 | data["comment"] = comment 434 | data["link"] = link 435 | 436 | if response := api.post(cls.URL, data): 437 | logger.info(f"Link attachment added to {cls.URL}") 438 | else: 439 | logger.error(f"Link attachment failed at {cls.URL}") 440 | 441 | return response 442 | 443 | @classmethod 444 | def upload(cls, api, attachment, comment='', **kwargs): 445 | """ 446 | Upload a file attachment. 447 | Ref: https://2.python-requests.org/en/master/user/quickstart/#post-a-multipart-encoded-file 448 | 449 | Args: 450 | api: Authenticated InvenTree API instance 451 | attachment: Either a file object, or a filename (string) 452 | comment: Add comment to the upload 453 | kwargs: Additional kwargs to supply 454 | """ 455 | 456 | data = kwargs 457 | data['comment'] = comment 458 | 459 | if type(attachment) is str: 460 | if not os.path.exists(attachment): 461 | raise FileNotFoundError(f"Attachment file '{attachment}' does not exist") 462 | 463 | # Load the file as an in-memory file object 464 | with open(attachment, 'rb') as fo: 465 | response = api.post( 466 | Attachment.URL, 467 | data, 468 | files={ 469 | 'attachment': (os.path.basename(attachment), fo), 470 | } 471 | ) 472 | 473 | else: 474 | # Assumes a StringIO or BytesIO like object 475 | name = getattr(attachment, 'name', 'filename') 476 | 477 | response = api.post( 478 | Attachment.URL, 479 | data, 480 | files={ 481 | 'attachment': (name, attachment), 482 | } 483 | ) 484 | 485 | if response: 486 | logger.info(f"File uploaded to {cls.URL}") 487 | else: 488 | logger.error(f"File upload failed at {cls.URL}") 489 | 490 | return response 491 | 492 | def download(self, destination, **kwargs): 493 | """ 494 | Download the attachment file to the specified location 495 | """ 496 | 497 | return self._api.downloadFile(self.attachment, destination, **kwargs) 498 | 499 | 500 | class AttachmentMixin: 501 | """Mixin class which allows a model class to interact with attachments.""" 502 | 503 | def getAttachments(self): 504 | """Return a list of attachments associated with this object.""" 505 | 506 | return Attachment.list( 507 | self._api, 508 | model_type=self.getModelType(), 509 | model_id=self.pk 510 | ) 511 | 512 | def uploadAttachment(self, attachment, comment=""): 513 | """Upload a file attachment against this model instance.""" 514 | 515 | return Attachment.upload( 516 | self._api, 517 | attachment, 518 | comment=comment, 519 | model_type=self.getModelType(), 520 | model_id=self.pk 521 | ) 522 | 523 | def addLinkAttachment(self, link, comment=""): 524 | """Add an external link attachment against this Object. 525 | 526 | Args: 527 | link: The link to attach 528 | comment: Attachment comment 529 | """ 530 | 531 | return Attachment.add_link( 532 | self._api, 533 | link, 534 | comment=comment, 535 | model_type=self.getModelType(), 536 | model_id=self.pk 537 | ) 538 | 539 | 540 | class MetadataMixin: 541 | """Mixin class for models which support a 'metadata' attribute. 542 | 543 | - The 'metadata' is not used for any InvenTree business logic 544 | - Instead it can be used by plugins for storing arbitrary information 545 | - Internally it is stored as a JSON database field 546 | - Metadata is accessed via the API by appending '/metadata/' to the API URL 547 | 548 | Note: Requires server API version 49 or newer 549 | 550 | """ 551 | 552 | @property 553 | def metadata_url(self): 554 | return os.path.join(self._url, "metadata/") 555 | 556 | def getMetadata(self): 557 | """Read model instance metadata""" 558 | if self._api: 559 | 560 | response = self._api.get(self.metadata_url) 561 | 562 | return response['metadata'] 563 | else: 564 | raise AttributeError(f"model.getMetadata failed at '{self._url}': No API instance provided") 565 | 566 | def setMetadata(self, data, overwrite=False): 567 | """Write metadata to this particular model. 568 | 569 | Arguments: 570 | data: The data to be written. Must be a dict object 571 | overwrite: If true, provided data replaces existing data. If false (default) data is merged with any existing data. 572 | """ 573 | 574 | if type(data) is not dict: 575 | raise TypeError("Data provided to 'setMetadata' method must be a dict object") 576 | 577 | if self._api: 578 | 579 | if overwrite: 580 | return self._api.put( 581 | self.metadata_url, 582 | data={ 583 | "metadata": data, 584 | } 585 | ) 586 | else: 587 | return self._api.patch( 588 | self.metadata_url, 589 | data={ 590 | "metadata": data 591 | } 592 | ) 593 | else: 594 | raise AttributeError(f"model.setMetadata failed at '{self._url}': No API instance provided") 595 | 596 | 597 | class ImageMixin: 598 | """Mixin class for supporting image upload against a model. 599 | 600 | - The model must have a specific 'image' field associated 601 | """ 602 | 603 | def uploadImage(self, image): 604 | """ 605 | Upload an image against this model. 606 | 607 | Args: 608 | image: Either an image file (BytesIO) or a filename path 609 | """ 610 | 611 | files = {} 612 | 613 | # string image = filename 614 | if type(image) is str: 615 | if os.path.exists(image): 616 | f = os.path.basename(image) 617 | 618 | with open(image, 'rb') as fo: 619 | files['image'] = (f, fo) 620 | 621 | return self.save( 622 | data={}, 623 | files=files 624 | ) 625 | else: 626 | raise FileNotFoundError(f"Image file does not exist: '{image}'") 627 | 628 | # TODO: Support upload of in-memory images (e.g. Image / BytesIO) 629 | 630 | else: 631 | raise TypeError(f"uploadImage called with invalid image: '{image}'") 632 | 633 | def downloadImage(self, destination, **kwargs): 634 | """ 635 | Download the image for this Part, to the specified destination 636 | """ 637 | 638 | if self.image: 639 | return self._api.downloadFile(self.image, destination, **kwargs) 640 | else: 641 | raise ValueError(f"Part '{self.name}' does not have an associated image") 642 | 643 | 644 | class StatusMixin: 645 | """Class adding functionality to assign a new status by calling 646 | - complete 647 | - cancel 648 | on supported items. 649 | 650 | Other functions, such as 651 | - ship 652 | - finish 653 | - issue 654 | can be reached through _statusupdate function 655 | """ 656 | 657 | def _statusupdate(self, status: str, reload=True, data=None, **kwargs): 658 | 659 | # Check status 660 | if status not in [ 661 | 'complete', 662 | 'cancel', 663 | 'hold', 664 | 'ship', 665 | 'issue', 666 | 'finish', 667 | ]: 668 | raise ValueError(f"Order stats {status} not supported.") 669 | 670 | # Set the url 671 | URL = self.URL + f"/{self.pk}/{status}" 672 | 673 | if data is None: 674 | data = {} 675 | 676 | data.update(kwargs) 677 | 678 | # Send data 679 | response = self._api.post(URL, data) 680 | 681 | # Reload 682 | if reload: 683 | self.reload() 684 | 685 | # Return 686 | return response 687 | 688 | def complete(self, **kwargs): 689 | 690 | return self._statusupdate(status='complete', **kwargs) 691 | 692 | def cancel(self, **kwargs): 693 | 694 | return self._statusupdate(status='cancel', **kwargs) 695 | 696 | 697 | class BarcodeMixin: 698 | """Adds barcode scanning functionality to various data types. 699 | 700 | Any class which inherits from this mixin can assign (or un-assign) barcode data. 701 | """ 702 | 703 | @classmethod 704 | def barcodeModelType(cls): 705 | """Return the model type name required for barcode assignment. 706 | 707 | Default value is the lower-case class name () 708 | """ 709 | return cls.__name__.lower() 710 | 711 | def assignBarcode(self, barcode_data: str, reload=True): 712 | """Assign an arbitrary barcode to this object (in the database). 713 | 714 | Arguments: 715 | barcode_data: A string containing arbitrary barcode data 716 | """ 717 | 718 | model_type = self.barcodeModelType() 719 | 720 | response = self._api.post( 721 | '/barcode/link/', 722 | { 723 | 'barcode': barcode_data, 724 | model_type: self.pk, 725 | } 726 | ) 727 | 728 | if reload: 729 | self.reload() 730 | 731 | return response 732 | 733 | def unassignBarcode(self, reload=True): 734 | """Unassign a barcode from this object""" 735 | 736 | model_type = self.barcodeModelType() 737 | 738 | response = self._api.post( 739 | '/barcode/unlink/', 740 | { 741 | model_type: self.pk, 742 | } 743 | ) 744 | 745 | if reload: 746 | self.reload() 747 | 748 | return response 749 | -------------------------------------------------------------------------------- /inventree/build.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import inventree.base 4 | import inventree.report 5 | 6 | 7 | class Build( 8 | inventree.base.AttachmentMixin, 9 | inventree.base.StatusMixin, 10 | inventree.base.MetadataMixin, 11 | inventree.report.ReportPrintingMixin, 12 | inventree.base.InventreeObject, 13 | ): 14 | """ Class representing the Build database model """ 15 | 16 | URL = 'build' 17 | MODEL_TYPE = 'build' 18 | 19 | def issue(self): 20 | """Mark this build as 'issued'.""" 21 | return self._statusupdate(status='issue') 22 | 23 | def hold(self): 24 | """Mark this build as 'on hold'.""" 25 | return self._statusupdate(status='hold') 26 | 27 | def complete( 28 | self, 29 | accept_overallocated='reject', 30 | accept_unallocated=False, 31 | accept_incomplete=False, 32 | ): 33 | """Finish a build order. Takes the following flags: 34 | - accept_overallocated 35 | - accept_unallocated 36 | - accept_incomplete 37 | """ 38 | return self._statusupdate( 39 | status='finish', 40 | data={ 41 | 'accept_overallocated': accept_overallocated, 42 | 'accept_unallocated': accept_unallocated, 43 | 'accept_incomplete': accept_incomplete, 44 | } 45 | ) 46 | 47 | def finish(self, *args, **kwargs): 48 | """Alias for complete""" 49 | return self.complete(*args, **kwargs) 50 | -------------------------------------------------------------------------------- /inventree/company.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | import inventree.base 6 | import inventree.order 7 | 8 | logger = logging.getLogger('inventree') 9 | 10 | 11 | class Contact(inventree.base.InventreeObject): 12 | """Class representing the Contact model""" 13 | 14 | URL = 'company/contact/' 15 | MIN_API_VERSION = 104 16 | 17 | 18 | class Address(inventree.base.InventreeObject): 19 | """Class representing the Address model""" 20 | 21 | URL = 'company/address/' 22 | MIN_API_VERSION = 126 23 | 24 | 25 | class Company(inventree.base.ImageMixin, inventree.base.MetadataMixin, inventree.base.InventreeObject): 26 | """ Class representing the Company database model """ 27 | 28 | URL = 'company' 29 | MODEL_TYPE = "company" 30 | 31 | def getContacts(self, **kwargs): 32 | """Return contacts associated with this Company""" 33 | kwargs['company'] = self.pk 34 | return Contact.list(self._api, **kwargs) 35 | 36 | def getAddresses(self, **kwargs): 37 | """Return addresses associated with this Company""" 38 | kwargs['company'] = self.pk 39 | return Address.list(self._api, **kwargs) 40 | 41 | def getSuppliedParts(self, **kwargs): 42 | """ 43 | Return list of SupplierPart objects supplied by this Company 44 | """ 45 | return SupplierPart.list(self._api, supplier=self.pk, **kwargs) 46 | 47 | def getManufacturedParts(self, **kwargs): 48 | """ 49 | Return list of ManufacturerPart objects manufactured by this Company 50 | """ 51 | return ManufacturerPart.list(self._api, manufacturer=self.pk, **kwargs) 52 | 53 | def getPurchaseOrders(self, **kwargs): 54 | """ 55 | Return list of PurchaseOrder objects associated with this company 56 | """ 57 | return inventree.order.PurchaseOrder.list(self._api, supplier=self.pk, **kwargs) 58 | 59 | def createPurchaseOrder(self, **kwargs): 60 | """ 61 | Create (and return) a new PurchaseOrder against this company 62 | """ 63 | 64 | kwargs['supplier'] = self.pk 65 | 66 | return inventree.order.PurchaseOrder.create( 67 | self._api, 68 | data=kwargs 69 | ) 70 | 71 | def getSalesOrders(self, **kwargs): 72 | """ 73 | Return list of SalesOrder objects associated with this company 74 | """ 75 | return inventree.order.SalesOrder.list(self._api, customer=self.pk, **kwargs) 76 | 77 | def createSalesOrder(self, **kwargs): 78 | """ 79 | Create (and return) a new SalesOrder against this company 80 | """ 81 | 82 | kwargs['customer'] = self.pk 83 | 84 | return inventree.order.SalesOrder.create( 85 | self._api, 86 | data=kwargs 87 | ) 88 | 89 | def getReturnOrders(self, **kwargs): 90 | """Return list of ReturnOrder objects associated with this company""" 91 | return inventree.order.ReturnOrder.list(self._api, customer=self.pk, **kwargs) 92 | 93 | def createReturnOrder(self, **kwargs): 94 | """Create (and return) a new ReturnOrder against this company""" 95 | kwargs['customer'] = self.pk 96 | 97 | return inventree.order.ReturnOrder.create(self._api, data=kwargs) 98 | 99 | 100 | class SupplierPart(inventree.base.BarcodeMixin, inventree.base.BulkDeleteMixin, inventree.base.MetadataMixin, inventree.base.InventreeObject): 101 | """Class representing the SupplierPart database model 102 | 103 | - Implements the BulkDeleteMixin 104 | """ 105 | 106 | URL = 'company/part' 107 | 108 | def getPriceBreaks(self): 109 | """ Get a list of price break objects for this SupplierPart """ 110 | 111 | return SupplierPriceBreak.list(self._api, part=self.pk) 112 | 113 | 114 | class ManufacturerPart( 115 | inventree.base.AttachmentMixin, 116 | inventree.base.BulkDeleteMixin, 117 | inventree.base.MetadataMixin, 118 | inventree.base.InventreeObject, 119 | ): 120 | """Class representing the ManufacturerPart database model 121 | 122 | - Implements the BulkDeleteMixin 123 | """ 124 | 125 | URL = 'company/part/manufacturer' 126 | MODEL_TYPE = "manufacturerpart" 127 | 128 | def getParameters(self, **kwargs): 129 | """ 130 | GET a list of all ManufacturerPartParameter objects for this ManufacturerPart 131 | """ 132 | 133 | return ManufacturerPartParameter.list(self._api, manufacturer_part=self.pk, **kwargs) 134 | 135 | 136 | class ManufacturerPartParameter(inventree.base.BulkDeleteMixin, inventree.base.InventreeObject): 137 | """Class representing the ManufacturerPartParameter database model. 138 | 139 | - Implements the BulkDeleteMixin 140 | """ 141 | 142 | URL = 'company/part/manufacturer/parameter' 143 | 144 | 145 | class SupplierPriceBreak(inventree.base.InventreeObject): 146 | """ Class representing the SupplierPriceBreak database model """ 147 | 148 | URL = 'company/price-break' 149 | -------------------------------------------------------------------------------- /inventree/currency.py: -------------------------------------------------------------------------------- 1 | """Manages currency / conversion support for InvenTree""" 2 | 3 | import logging 4 | 5 | logger = logging.getLogger('inventree') 6 | 7 | 8 | class CurrencyManager(object): 9 | """Class for managing InvenTree currency support""" 10 | 11 | # Currency API endpoint 12 | CURRENCY_ENDPOINT = 'currency/exchange/' 13 | 14 | def __init__(self, api): 15 | """Construct a CurrencyManager instance""" 16 | 17 | # Store internal reference to the API 18 | self.api = api 19 | 20 | self.base_currency = None 21 | self.exchange_rates = None 22 | 23 | def refreshExchangeRates(self): 24 | """Request the server update exchange rates from external service""" 25 | 26 | return self.api.post('currency/refresh/', {}) 27 | 28 | def updateFromServer(self): 29 | """Retrieve currency data from the server""" 30 | 31 | response = self.api.get(self.CURRENCY_ENDPOINT) 32 | 33 | if response is None: 34 | logger.error("Could not retrieve currency data from InvenTree server") 35 | return 36 | 37 | self.base_currency = response.get('base_currency', None) 38 | self.exchange_rates = response.get('exchange_rates', None) 39 | 40 | if self.base_currency is None: 41 | logger.warning("'base_currency' missing from server response") 42 | 43 | if self.exchange_rates is None: 44 | logger.warning("'exchange_rates' missing from server response") 45 | 46 | def getBaseCurrency(self, cache=True): 47 | """Return the base currency code (e.g. 'USD') from the server""" 48 | 49 | if not cache or not self.base_currency: 50 | self.updateFromServer() 51 | 52 | return self.base_currency 53 | 54 | def getExchangeRates(self, cache=True): 55 | """Return the exchange rate information from the server""" 56 | 57 | if not cache or not self.exchange_rates: 58 | self.updateFromServer() 59 | 60 | return self.exchange_rates 61 | 62 | def convertCurrency(self, value, source_currency, target_currency, cache=True): 63 | """Convert between currencies 64 | 65 | Arguments: 66 | value: The numerical currency value to be converted 67 | source_currency: The source currency code (e.g. 'USD') 68 | target_currency: The target currency code (e.g. 'NZD') 69 | """ 70 | 71 | # Shortcut if the currencies are the same 72 | if source_currency == target_currency: 73 | return value 74 | 75 | base = self.getBaseCurrency(cache=cache) 76 | rates = self.getExchangeRates(cache=cache) 77 | 78 | if base is None: 79 | raise AttributeError("Base currency information is not available") 80 | 81 | if rates is None: 82 | raise AttributeError("Exchange rate information is not available") 83 | 84 | if source_currency not in rates: 85 | raise NameError(f"Source currency code '{source_currency}' not found in exchange rate data") 86 | 87 | if target_currency not in rates: 88 | raise NameError(f"Target currency code '{target_currency}' not found in exchange rate data") 89 | 90 | return value / rates[source_currency] * rates[target_currency] 91 | -------------------------------------------------------------------------------- /inventree/label.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import os 5 | 6 | import inventree.base 7 | 8 | logger = logging.getLogger('inventree') 9 | 10 | 11 | class LabelPrintingMixin: 12 | """Mixin class for label printing.""" 13 | 14 | LABELNAME = '' 15 | LABELITEM = '' 16 | 17 | def getTemplateId(self, template): 18 | """Return the ID (pk) from the supplied template.""" 19 | 20 | if type(template) in [str, int]: 21 | return int(template) 22 | 23 | if hasattr(template, 'pk'): 24 | return int(template.pk) 25 | 26 | raise ValueError(f"Provided label template is not a valid type: {type(template)}") 27 | 28 | def saveOutput(self, output, filename): 29 | """Save the output from a label printing job to the specified file path.""" 30 | 31 | if os.path.exists(filename) and os.path.isdir(filename): 32 | filename = os.path.join( 33 | filename, 34 | f'Label_{self.getModelType()}_{self.pk}.pdf' 35 | ) 36 | 37 | return self._api.downloadFile(url=output, destination=filename) 38 | 39 | def printLabel(self, template, plugin=None, destination=None, *args, **kwargs): 40 | """Print a label against the provided label template.""" 41 | 42 | print_url = '/label/print/' 43 | 44 | template_id = self.getTemplateId(template) 45 | 46 | data = { 47 | 'template': template_id, 48 | 'items': [self.pk] 49 | } 50 | 51 | if plugin is not None: 52 | # For the modern printing API, plugin is provided as a key (string) value 53 | if type(plugin) is str: 54 | pass 55 | elif hasattr(plugin, 'key'): 56 | plugin = plugin.key 57 | else: 58 | raise ValueError(f"Invalid plugin provided: {type(plugin)}") 59 | 60 | data['plugin'] = plugin 61 | 62 | response = self._api.post( 63 | print_url, 64 | data=data 65 | ) 66 | 67 | output = response.get('output', None) 68 | 69 | if output and destination: 70 | return self.saveOutput(output, destination) 71 | else: 72 | return response 73 | 74 | def getLabelTemplates(self, **kwargs): 75 | """Return a list of label templates for this model class.""" 76 | 77 | return LabelTemplate.list( 78 | self._api, 79 | model_type=self.getModelType(), 80 | **kwargs 81 | ) 82 | 83 | 84 | class LabelFunctions(inventree.base.MetadataMixin, inventree.base.InventreeObject): 85 | """Base class for label functions.""" 86 | 87 | @property 88 | def template_key(self): 89 | """Return the attribute name for the template file.""" 90 | 91 | return 'template' 92 | 93 | @classmethod 94 | def create(cls, api, data, label, **kwargs): 95 | """Create a new label by uploading a label template file. Convenience wrapper around base create() method. 96 | 97 | Args: 98 | data: Dict of data including at least name and description for the template 99 | label: Either a string (filename) or a file object 100 | """ 101 | 102 | # POST endpoints for creating new reports were added in API version 156 103 | cls.MIN_API_VERSION = 156 104 | 105 | try: 106 | # If label is already a readable object, don't convert it 107 | if label.readable() is False: 108 | raise ValueError("Label template file must be readable") 109 | except AttributeError: 110 | label = open(label) 111 | if label.readable() is False: 112 | raise ValueError("Label template file must be readable") 113 | 114 | try: 115 | response = super().create(api, data=data, files={'template': label}, **kwargs) 116 | finally: 117 | if label is not None: 118 | label.close() 119 | return response 120 | 121 | def save(self, data=None, label=None, **kwargs): 122 | """Save label to database. Convenience wrapper around save() method. 123 | 124 | Args: 125 | data (optional): Dict of data to change for the template. 126 | label (optional): Either a string (filename) or a file object, to upload a new label template 127 | """ 128 | 129 | if label is not None: 130 | try: 131 | # If template is already a readable object, don't convert it 132 | if label.readable() is False: 133 | raise ValueError("Label template file must be readable") 134 | except AttributeError: 135 | label = open(label, 'r') 136 | if label.readable() is False: 137 | raise ValueError("Label template file must be readable") 138 | 139 | if 'files' in kwargs: 140 | files = kwargs.pop('kwargs') 141 | files[self.template_key] = label 142 | else: 143 | files = {self.template_key: label} 144 | else: 145 | files = None 146 | 147 | try: 148 | response = super().save(data=data, files=files) 149 | finally: 150 | if label is not None: 151 | label.close() 152 | return response 153 | 154 | def downloadTemplate(self, destination, overwrite=False): 155 | """Download template file for the label to the given destination""" 156 | 157 | # Use downloadFile method to get the file 158 | return self._api.downloadFile(url=self._data[self.template_key], destination=destination, overwrite=overwrite) 159 | 160 | 161 | class LabelTemplate(LabelFunctions): 162 | """Class representing the LabelTemplate database model.""" 163 | 164 | URL = 'label/template' 165 | 166 | def __str__(self): 167 | """String representation of the LabelTemplate instance.""" 168 | 169 | return f"LabelTemplate <{self.pk}>: '{self.name}' - ({self.model_type})" 170 | -------------------------------------------------------------------------------- /inventree/order.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file serves a a 'proxy' for various order models, 3 | but the source for these models has now been moved into separate files 4 | """ 5 | 6 | # Pass PurchaseOrder models through 7 | from inventree.purchase_order import PurchaseOrder # noqa:F401 8 | from inventree.purchase_order import PurchaseOrderExtraLineItem # noqa:F401 9 | from inventree.purchase_order import PurchaseOrderLineItem # noqa:F401 10 | # Pass ReturnOrder models through 11 | from inventree.return_order import ReturnOrder # noqa:F401 12 | from inventree.return_order import ReturnOrderExtraLineItem # noqa:F401 13 | from inventree.return_order import ReturnOrderLineItem # noqa:F401 14 | # Pass SalesOrder models through 15 | from inventree.sales_order import SalesOrder # noqa:F401 16 | from inventree.sales_order import SalesOrderExtraLineItem # noqa:F401 17 | from inventree.sales_order import SalesOrderLineItem # noqa:F401 18 | from inventree.sales_order import SalesOrderShipment # noqa:F401 19 | -------------------------------------------------------------------------------- /inventree/part.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import re 5 | 6 | import inventree.base 7 | import inventree.build 8 | import inventree.company 9 | import inventree.label 10 | import inventree.report 11 | import inventree.stock 12 | 13 | logger = logging.getLogger('inventree') 14 | 15 | 16 | class PartCategoryParameterTemplate(inventree.base.InventreeObject): 17 | """A model which link a ParameterTemplate to a PartCategory""" 18 | 19 | URL = 'part/category/parameters' 20 | 21 | def getCategory(self): 22 | """Return the referenced PartCategory instance""" 23 | return PartCategory(self._api, self.category) 24 | 25 | def getTemplate(self): 26 | """Return the referenced ParameterTemplate instance""" 27 | return ParameterTemplate(self._api, self.parameter_template) 28 | 29 | 30 | class PartCategory(inventree.base.MetadataMixin, inventree.base.InventreeObject): 31 | """ Class representing the PartCategory database model """ 32 | 33 | URL = 'part/category' 34 | 35 | def getParts(self, **kwargs): 36 | return Part.list(self._api, category=self.pk, **kwargs) 37 | 38 | def getParentCategory(self): 39 | if self.parent: 40 | return PartCategory(self._api, self.parent) 41 | else: 42 | return None 43 | 44 | def getChildCategories(self, **kwargs): 45 | return PartCategory.list(self._api, parent=self.pk, **kwargs) 46 | 47 | def getCategoryParameterTemplates(self, fetch_parent: bool = True) -> list: 48 | """Fetch a list of default parameter templates associated with this category 49 | 50 | Arguments: 51 | fetch_parent: If True (default) include templates for parents also 52 | """ 53 | 54 | return PartCategoryParameterTemplate.list( 55 | self._api, 56 | category=self.pk, 57 | fetch_parent=fetch_parent 58 | ) 59 | 60 | 61 | class Part( 62 | inventree.base.AttachmentMixin, 63 | inventree.base.BarcodeMixin, 64 | inventree.base.MetadataMixin, 65 | inventree.base.ImageMixin, 66 | inventree.label.LabelPrintingMixin, 67 | inventree.base.InventreeObject, 68 | ): 69 | """ Class representing the Part database model """ 70 | 71 | URL = 'part' 72 | MODEL_TYPE = 'part' 73 | 74 | def getCategory(self): 75 | """ Return the part category associated with this part """ 76 | return PartCategory(self._api, self.category) 77 | 78 | def getTestTemplates(self): 79 | """ Return all test templates associated with this part """ 80 | return PartTestTemplate.list(self._api, part=self.pk) 81 | 82 | def getSupplierParts(self): 83 | """ Return the supplier parts associated with this part """ 84 | if self.purchaseable: 85 | return inventree.company.SupplierPart.list(self._api, part=self.pk) 86 | else: 87 | return list() 88 | 89 | def getManufacturerParts(self): 90 | """ Return the manufacturer parts associated with this part """ 91 | return inventree.company.ManufacturerPart.list(self._api, part=self.pk) 92 | 93 | def getBomItems(self, **kwargs): 94 | """ Return the items required to make this part """ 95 | return BomItem.list(self._api, part=self.pk, **kwargs) 96 | 97 | def isUsedIn(self): 98 | """ Return a list of all the parts this part is used in """ 99 | return BomItem.list(self._api, uses=self.pk) 100 | 101 | def getBuilds(self, **kwargs): 102 | """ Return the builds associated with this part """ 103 | return inventree.build.Build.list(self._api, part=self.pk, **kwargs) 104 | 105 | def getStockItems(self, **kwargs): 106 | """ Return the stock items associated with this part """ 107 | return inventree.stock.StockItem.list(self._api, part=self.pk, **kwargs) 108 | 109 | def getParameters(self): 110 | """ Return parameters associated with this part """ 111 | return Parameter.list(self._api, part=self.pk) 112 | 113 | def getRelated(self): 114 | """ Return related parts associated with this part """ 115 | return PartRelated.list(self._api, part=self.pk) 116 | 117 | def getInternalPriceList(self): 118 | """ 119 | Returns the InternalPrice list for this part 120 | """ 121 | 122 | return InternalPrice.list(self._api, part=self.pk) 123 | 124 | def setInternalPrice(self, quantity: int, price: float): 125 | """ 126 | Set the internal price for this part 127 | """ 128 | 129 | return InternalPrice.setInternalPrice(self._api, self.pk, quantity, price) 130 | 131 | def getSalePrice(self): 132 | """ 133 | Get sales prices for this part 134 | """ 135 | return SalePrice.list(self._api, part=self.pk)[0].price 136 | 137 | def getRequirements(self): 138 | """ 139 | Get required amounts from requirements API endpoint for this part 140 | """ 141 | 142 | # Set the url 143 | URL = f"{self.URL}/{self.pk}/requirements/" 144 | 145 | # Get data 146 | return self._api.get(URL) 147 | 148 | 149 | class PartTestTemplate(inventree.base.MetadataMixin, inventree.base.InventreeObject): 150 | """ Class representing a test template for a Part """ 151 | 152 | URL = 'part/test-template' 153 | 154 | @classmethod 155 | def generateTestKey(cls, test_name): 156 | """ Generate a 'key' for this test """ 157 | 158 | key = test_name.strip().lower() 159 | key = key.replace(' ', '') 160 | 161 | # Remove any characters that cannot be used to represent a variable 162 | key = re.sub(r'[^a-zA-Z0-9]', '', key) 163 | 164 | return key 165 | 166 | def getTestKey(self): 167 | """Return the 'key' for this test. 168 | 169 | Note that after API v169, the 'key' parameter is also directly accessible 170 | """ 171 | 172 | # Try to return the key - fall back to generateTestKey 173 | try: 174 | return self.key 175 | except AttributeError: 176 | return self.generateTestKey(self.test_name) 177 | 178 | 179 | class BomItem( 180 | inventree.base.InventreeObject, 181 | inventree.base.MetadataMixin, 182 | inventree.report.ReportPrintingMixin, 183 | ): 184 | """ Class representing the BomItem database model """ 185 | 186 | URL = 'bom' 187 | 188 | 189 | class BomItemSubstitute( 190 | inventree.base.InventreeObject, 191 | inventree.base.MetadataMixin, 192 | ): 193 | """Class representing the BomItemSubstitute database model""" 194 | 195 | URL = "bom/substitute" 196 | 197 | 198 | class InternalPrice(inventree.base.InventreeObject): 199 | """ Class representing the InternalPrice model """ 200 | 201 | URL = 'part/internal-price' 202 | 203 | @classmethod 204 | def setInternalPrice(cls, api, part, quantity: int, price: float): 205 | """ 206 | Set the internal price for this part 207 | """ 208 | 209 | data = { 210 | 'part': part, 211 | 'quantity': quantity, 212 | 'price': price, 213 | } 214 | 215 | # Send the data to the server 216 | return api.post(cls.URL, data) 217 | 218 | 219 | class SalePrice(inventree.base.InventreeObject): 220 | """ Class representing the SalePrice model """ 221 | 222 | URL = 'part/sale-price' 223 | 224 | @classmethod 225 | def setSalePrice(cls, api, part, quantity: int, price: float, price_currency: str): 226 | """ 227 | Set the sale price for this part 228 | """ 229 | 230 | data = { 231 | 'part': part, 232 | 'quantity': quantity, 233 | 'price': price, 234 | 'price_currency': price_currency, 235 | } 236 | 237 | # Send the data to the server 238 | return api.post(cls.URL, data) 239 | 240 | 241 | class PartRelated(inventree.base.InventreeObject): 242 | """ Class representing a relationship between parts""" 243 | 244 | URL = 'part/related' 245 | 246 | @classmethod 247 | def add_related(cls, api, part1, part2): 248 | 249 | if isinstance(part1, Part): 250 | pk_1 = part1.pk 251 | else: 252 | pk_1 = int(part1) 253 | if isinstance(part2, Part): 254 | pk_2 = part2.pk 255 | else: 256 | pk_2 = int(part2) 257 | 258 | data = { 259 | 'part_1': pk_1, 260 | 'part_2': pk_2, 261 | } 262 | 263 | # Send the data to the server 264 | return api.post(cls.URL, data) 265 | 266 | 267 | class Parameter(inventree.base.InventreeObject): 268 | """class representing the Parameter database model """ 269 | URL = 'part/parameter' 270 | 271 | def getunits(self): 272 | """ Get the units for this parameter """ 273 | 274 | return self._data['template_detail']['units'] 275 | 276 | 277 | class ParameterTemplate(inventree.base.InventreeObject): 278 | """ class representing the Parameter Template database model""" 279 | 280 | URL = 'part/parameter/template' 281 | -------------------------------------------------------------------------------- /inventree/plugin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import inventree.base 4 | 5 | 6 | class InvenTreePlugin(inventree.base.MetadataMixin, inventree.base.InventreeObject): 7 | """Represents a PluginConfig instance on the InvenTree server.""" 8 | 9 | URL = 'plugins' 10 | MIN_API_VERSION = 197 11 | 12 | @classmethod 13 | def getPkField(cls): 14 | """Return the primary key field for the PluginConfig object.""" 15 | return 'key' 16 | 17 | def setActive(self, active: bool): 18 | """Activate or deactivate this plugin.""" 19 | 20 | url = f'plugins/{self.pk}/activate/' 21 | 22 | self._api.post(url, data={'active': active}) 23 | -------------------------------------------------------------------------------- /inventree/project_code.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | 4 | import inventree.base 5 | 6 | logger = logging.getLogger('inventree') 7 | 8 | 9 | class ProjectCode(inventree.base.InventreeObject): 10 | """Class representing the 'ProjectCode' database model""" 11 | 12 | URL = 'project-code/' 13 | MIN_API_VERSION = 109 14 | -------------------------------------------------------------------------------- /inventree/purchase_order.py: -------------------------------------------------------------------------------- 1 | """ 2 | PurchaseOrder models 3 | """ 4 | 5 | import inventree.base 6 | import inventree.company 7 | import inventree.part 8 | import inventree.report 9 | 10 | 11 | class PurchaseOrder( 12 | inventree.base.AttachmentMixin, 13 | inventree.base.MetadataMixin, 14 | inventree.base.StatusMixin, 15 | inventree.report.ReportPrintingMixin, 16 | inventree.base.InventreeObject, 17 | ): 18 | """ Class representing the PurchaseOrder database model """ 19 | 20 | URL = 'order/po' 21 | MODEL_TYPE = 'purchaseorder' 22 | 23 | def getSupplier(self): 24 | """Return the supplier (Company) associated with this order""" 25 | return inventree.company.Company(self._api, self.supplier) 26 | 27 | def getContact(self): 28 | """Return the contact associated with this order""" 29 | if self.contact is not None: 30 | return inventree.company.Contact(self._api, self.contact) 31 | else: 32 | return None 33 | 34 | def getLineItems(self, **kwargs): 35 | """ Return the line items associated with this order """ 36 | return PurchaseOrderLineItem.list(self._api, order=self.pk, **kwargs) 37 | 38 | def getExtraLineItems(self, **kwargs): 39 | """ Return the line items associated with this order """ 40 | return PurchaseOrderExtraLineItem.list(self._api, order=self.pk, **kwargs) 41 | 42 | def addLineItem(self, **kwargs): 43 | """ 44 | Create (and return) new PurchaseOrderLineItem object against this PurchaseOrder 45 | """ 46 | 47 | kwargs['order'] = self.pk 48 | 49 | return PurchaseOrderLineItem.create(self._api, data=kwargs) 50 | 51 | def addExtraLineItem(self, **kwargs): 52 | """ 53 | Create (and return) new PurchaseOrderExtraLineItem object against this PurchaseOrder 54 | """ 55 | 56 | kwargs['order'] = self.pk 57 | 58 | return PurchaseOrderExtraLineItem.create(self._api, data=kwargs) 59 | 60 | def issue(self, **kwargs): 61 | """ 62 | Issue the purchase order 63 | """ 64 | 65 | # Return 66 | return self._statusupdate(status='issue', **kwargs) 67 | 68 | def hold(self, **kwargs): 69 | """ 70 | Hold the purchase order 71 | """ 72 | 73 | # Return 74 | return self._statusupdate(status='hold', **kwargs) 75 | 76 | def receiveAll(self, location, status=10): 77 | """ 78 | Receive all of the purchase order items, into the given location. 79 | 80 | Note that the location may be overwritten if a destination is saved in the PO for the line item. 81 | 82 | By default, the status is set to OK (Code 10). 83 | 84 | To modify the defaults, use the arguments: 85 | status: Status code 86 | 10 OK 87 | 50 ATTENTION 88 | 55 DAMAGED 89 | 60 DESTROYED 90 | 65 REJECTED 91 | 70 LOST 92 | 75 QUARANTINED 93 | 85 RETURNED 94 | """ 95 | 96 | # Check if location is a model - or try to get an integer 97 | try: 98 | location_id = location.pk 99 | except: # noqa:E722 100 | location_id = int(location) 101 | 102 | # Prepare request data 103 | items = list() 104 | for li in self.getLineItems(): 105 | quantity_to_receive = li.quantity - li.received 106 | # Make sure quantity > 0 107 | if quantity_to_receive > 0: 108 | items.append( 109 | { 110 | 'line_item': li.pk, 111 | 'supplier_part': li.part, 112 | 'quantity': quantity_to_receive, 113 | 'status': status, 114 | 'location': location_id, 115 | } 116 | ) 117 | 118 | # If nothing is left, quit here 119 | if len(items) < 1: 120 | return None 121 | 122 | data = { 123 | 'items': items, 124 | 'location': location_id 125 | } 126 | 127 | # Set the url 128 | URL = f"{self.URL}/{self.pk}/receive/" 129 | 130 | # Send data 131 | response = self._api.post(URL, data) 132 | 133 | # Reload 134 | self.reload() 135 | 136 | # Return 137 | return response 138 | 139 | 140 | class PurchaseOrderLineItem( 141 | inventree.base.InventreeObject, 142 | inventree.base.MetadataMixin, 143 | ): 144 | """ Class representing the PurchaseOrderLineItem database model """ 145 | 146 | URL = 'order/po-line' 147 | 148 | def getSupplierPart(self): 149 | """ 150 | Return the SupplierPart associated with this PurchaseOrderLineItem 151 | """ 152 | return inventree.company.SupplierPart(self._api, self.part) 153 | 154 | def getPart(self): 155 | """ 156 | Return the Part referenced by the associated SupplierPart 157 | """ 158 | return inventree.part.Part(self._api, self.getSupplierPart().part) 159 | 160 | def getOrder(self): 161 | """ 162 | Return the PurchaseOrder to which this PurchaseOrderLineItem belongs 163 | """ 164 | return PurchaseOrder(self._api, self.order) 165 | 166 | def receive(self, quantity=None, status=10, location=None, expiry_date=None, batch_code=None, serial_numbers=None): 167 | """ 168 | Mark this line item as received. 169 | 170 | By default, receives all remaining items in the order, and puts them in the destination defined in the PO. 171 | The status is set to OK (Code 10). 172 | 173 | To modify the defaults, use the arguments: 174 | quantity: Number of units to receive. If None, will calculate the quantity not yet received and receive these. 175 | status: Status code 176 | 10 OK 177 | 50 ATTENTION 178 | 55 DAMAGED 179 | 60 DESTROYED 180 | 65 REJECTED 181 | 70 LOST 182 | 75 QUARANTINED 183 | 85 RETURNED 184 | location: Location ID, or a StockLocation item 185 | 186 | If given, the following arguments are also sent as parameters: 187 | expiry_date 188 | batch_code 189 | serial_numbers 190 | """ 191 | 192 | if quantity is None: 193 | # Subtract number of already received lines from the order quantity 194 | quantity = self.quantity - self.received 195 | 196 | if location is None: 197 | location_id = self.destination 198 | else: 199 | # Check if location is a model - or try to get an integer 200 | try: 201 | location_id = location.pk 202 | except: # noqa:E722 203 | location_id = int(location) 204 | 205 | item_data = { 206 | 'line_item': self.pk, 207 | 'supplier_part': self.part, 208 | 'quantity': quantity, 209 | 'status': status, 210 | 'location': location_id 211 | } 212 | 213 | # Optional fields which may be set 214 | if expiry_date: 215 | item_data['expiry_date'] = expiry_date 216 | 217 | if batch_code: 218 | item_data['batch_code'] = batch_code 219 | 220 | if serial_numbers: 221 | item_data['serial_numbers'] = serial_numbers 222 | 223 | # Prepare request data 224 | data = { 225 | 'items': [ 226 | item_data, 227 | ], 228 | 'location': location_id 229 | } 230 | 231 | # Set the url 232 | URL = f"{self.getOrder().URL}/{self.getOrder().pk}/receive/" 233 | 234 | # Send data 235 | response = self._api.post(URL, data) 236 | 237 | # Reload 238 | self.reload() 239 | 240 | # Return 241 | return response 242 | 243 | 244 | class PurchaseOrderExtraLineItem( 245 | inventree.base.InventreeObject, 246 | inventree.base.MetadataMixin, 247 | ): 248 | """ Class representing the PurchaseOrderExtraLineItem database model """ 249 | 250 | URL = 'order/po-extra-line' 251 | 252 | def getOrder(self): 253 | """ 254 | Return the PurchaseOrder to which this PurchaseOrderLineItem belongs 255 | """ 256 | return PurchaseOrder(self._api, self.order) 257 | -------------------------------------------------------------------------------- /inventree/report.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import inventree.base 4 | 5 | 6 | class ReportPrintingMixin: 7 | """Mixin class for report printing.""" 8 | 9 | def getTemplateId(self, template): 10 | """Return the ID (pk) from the supplied template.""" 11 | 12 | if type(template) in [str, int]: 13 | return int(template) 14 | 15 | if hasattr(template, 'pk'): 16 | return int(template.pk) 17 | 18 | raise ValueError(f"Provided report template is not a valid type: {type(template)}") 19 | 20 | def printReport(self, report, destination=None, *args, **kwargs): 21 | """Print the report belonging to the given item. 22 | 23 | Set the report with 'report' argument, as the ID of the corresponding 24 | report. A corresponding report object can also be given. 25 | 26 | The file will be downloaded to 'destination'. 27 | Use overwrite=True to overwrite an existing file. 28 | 29 | If neither plugin nor destination is given, nothing will be done 30 | """ 31 | 32 | print_url = '/report/print/' 33 | template_id = self.getTemplateId(report) 34 | 35 | response = self._api.post( 36 | print_url, 37 | { 38 | 'template': template_id, 39 | 'items': [self.pk], 40 | } 41 | ) 42 | 43 | output = response.get('output', None) 44 | 45 | if output and destination: 46 | return self._api.downloadFile(url=output, destination=destination, *args, **kwargs) 47 | else: 48 | return response 49 | 50 | def getReportTemplates(self, **kwargs): 51 | """Return a list of report templates which match this model class.""" 52 | 53 | return ReportTemplate.list(self._api, model_type=self.getModelType(), **kwargs) 54 | 55 | 56 | class ReportFunctions(inventree.base.MetadataMixin, inventree.base.InventreeObject): 57 | """Base class for report functions""" 58 | 59 | @classmethod 60 | def create(cls, api, data, template, **kwargs): 61 | """Create a new report by uploading a template file. Convenience wrapper around base create() method. 62 | 63 | Args: 64 | data: Dict of data including at least name and description for the template 65 | template: Either a string (filename) or a file object 66 | """ 67 | 68 | try: 69 | # If template is already a readable object, don't convert it 70 | if template.readable() is False: 71 | raise ValueError("Template file must be readable") 72 | except AttributeError: 73 | template = open(template) 74 | if template.readable() is False: 75 | raise ValueError("Template file must be readable") 76 | 77 | try: 78 | response = super().create(api, data=data, files={'template': template}, **kwargs) 79 | finally: 80 | if template is not None: 81 | template.close() 82 | return response 83 | 84 | def save(self, data=None, template=None, **kwargs): 85 | """Save report data to database. Convenience wrapper around save() method. 86 | 87 | Args: 88 | data (optional): Dict of data to change for the template. 89 | template (optional): Either a string (filename) or a file object, to upload a new template 90 | """ 91 | 92 | if template is not None: 93 | try: 94 | # If template is already a readable object, don't convert it 95 | if template.readable() is False: 96 | raise ValueError("Template file must be readable") 97 | except AttributeError: 98 | template = open(template, 'r') 99 | if template.readable() is False: 100 | raise ValueError("Template file must be readable") 101 | 102 | if 'files' in kwargs: 103 | files = kwargs.pop('kwargs') 104 | files['template'] = template 105 | else: 106 | files = {'template': template} 107 | else: 108 | files = None 109 | 110 | try: 111 | response = super().save(data=data, files=files) 112 | finally: 113 | if template is not None: 114 | template.close() 115 | return response 116 | 117 | def downloadTemplate(self, destination, overwrite=False): 118 | """Download template file for the report to the given destination""" 119 | 120 | # Use downloadFile method to get the file 121 | return self._api.downloadFile(url=self._data['template'], destination=destination, overwrite=overwrite) 122 | 123 | 124 | class ReportTemplate(ReportFunctions): 125 | """Class representing the ReportTemplate model.""" 126 | 127 | URL = 'report/template' 128 | -------------------------------------------------------------------------------- /inventree/return_order.py: -------------------------------------------------------------------------------- 1 | """ 2 | ReturnOrder models 3 | """ 4 | 5 | import inventree.base 6 | import inventree.company 7 | import inventree.part 8 | import inventree.report 9 | import inventree.stock 10 | 11 | 12 | class ReturnOrder( 13 | inventree.base.AttachmentMixin, 14 | inventree.base.MetadataMixin, 15 | inventree.base.StatusMixin, 16 | inventree.report.ReportPrintingMixin, 17 | inventree.base.InventreeObject, 18 | ): 19 | """Class representing the ReturnOrder database model""" 20 | 21 | URL = 'order/ro' 22 | MIN_API_VERSION = 104 23 | MODEL_TYPE = 'returnorder' 24 | 25 | def getCustomer(self): 26 | """Return the customer associated with this order""" 27 | return inventree.company.Company(self._api, self.customer) 28 | 29 | def getContact(self): 30 | """Return the contact associated with this order""" 31 | if self.contact is not None: 32 | return inventree.company.Contact(self._api, self.contact) 33 | else: 34 | return None 35 | 36 | def getLineItems(self, **kwargs): 37 | """Return line items associated with this order""" 38 | return ReturnOrderLineItem.list(self._api, order=self.pk, **kwargs) 39 | 40 | def addLineItem(self, **kwargs): 41 | """Create (and return) a new ReturnOrderLineItem against this order""" 42 | kwargs['order'] = self.pk 43 | return ReturnOrderLineItem.create(self._api, data=kwargs) 44 | 45 | def getExtraLineItems(self, **kwargs): 46 | """Return the extra line items associated with this order""" 47 | return ReturnOrderExtraLineItem.list(self._api, order=self.pk, **kwargs) 48 | 49 | def addExtraLineItem(self, **kwargs): 50 | """Create (and return) a new ReturnOrderExtraLineItem against this order""" 51 | kwargs['order'] = self.pk 52 | return ReturnOrderExtraLineItem.create(self._api, data=kwargs) 53 | 54 | def issue(self, **kwargs): 55 | """Issue (send) this order""" 56 | return self._statusupdate(status='issue', **kwargs) 57 | 58 | def hold(self, **kwargs): 59 | """Place this order on hold""" 60 | return self._statusupdate(status='hold', **kwargs) 61 | 62 | def cancel(self, **kwargs): 63 | """Cancel this order""" 64 | return self._statusupdate(status='cancel', **kwargs) 65 | 66 | def complete(self, **kwargs): 67 | """Mark this order as complete""" 68 | return self._statusupdate(status='complete', **kwargs) 69 | 70 | 71 | class ReturnOrderLineItem(inventree.base.InventreeObject): 72 | """Class representing the ReturnOrderLineItem model""" 73 | 74 | URL = 'order/ro-line/' 75 | MIN_API_VERSION = 104 76 | 77 | def getOrder(self): 78 | """Return the ReturnOrder to which this ReturnOrderLineItem belongs""" 79 | return ReturnOrder(self._api, self.order) 80 | 81 | def getStockItem(self): 82 | """Return the StockItem associated with this line item""" 83 | return inventree.stock.StockItem(self._api, self.item) 84 | 85 | 86 | class ReturnOrderExtraLineItem(inventree.base.InventreeObject): 87 | """Class representing the ReturnOrderExtraLineItem model""" 88 | 89 | URL = 'order/ro-extra-line/' 90 | MIN_API_VERSION = 104 91 | 92 | def getOrder(self): 93 | """Return the ReturnOrder to which this line item belongs""" 94 | return ReturnOrder(self._api, self.order) 95 | -------------------------------------------------------------------------------- /inventree/sales_order.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sales Order models 3 | """ 4 | 5 | import inventree.base 6 | import inventree.company 7 | import inventree.part 8 | import inventree.report 9 | import inventree.stock 10 | 11 | 12 | class SalesOrder( 13 | inventree.base.AttachmentMixin, 14 | inventree.base.MetadataMixin, 15 | inventree.base.StatusMixin, 16 | inventree.report.ReportPrintingMixin, 17 | inventree.base.InventreeObject, 18 | ): 19 | """ Class representing the SalesOrder database model """ 20 | 21 | URL = 'order/so' 22 | MODEL_TYPE = 'salesorder' 23 | 24 | def getCustomer(self): 25 | """Return the customer associated with this order""" 26 | return inventree.company.Company(self._api, self.customer) 27 | 28 | def getContact(self): 29 | """Return the contact associated with this order""" 30 | if self.contact is not None: 31 | return inventree.company.Contact(self._api, self.contact) 32 | else: 33 | return None 34 | 35 | def getLineItems(self, **kwargs): 36 | """ Return the line items associated with this order """ 37 | return SalesOrderLineItem.list(self._api, order=self.pk, **kwargs) 38 | 39 | def getExtraLineItems(self, **kwargs): 40 | """ Return the line items associated with this order """ 41 | return SalesOrderExtraLineItem.list(self._api, order=self.pk, **kwargs) 42 | 43 | def addLineItem(self, **kwargs): 44 | """ 45 | Create (and return) new SalesOrderLineItem object against this SalesOrder 46 | """ 47 | 48 | kwargs['order'] = self.pk 49 | 50 | return SalesOrderLineItem.create(self._api, data=kwargs) 51 | 52 | def addExtraLineItem(self, **kwargs): 53 | """ 54 | Create (and return) new SalesOrderExtraLineItem object against this SalesOrder 55 | """ 56 | 57 | kwargs['order'] = self.pk 58 | 59 | return SalesOrderExtraLineItem.create(self._api, data=kwargs) 60 | 61 | def getShipments(self, **kwargs): 62 | """ Return the shipments associated with this order """ 63 | 64 | return SalesOrderShipment.list(self._api, order=self.pk, **kwargs) 65 | 66 | def addShipment(self, reference, **kwargs): 67 | """ Create (and return) new SalesOrderShipment 68 | against this SalesOrder """ 69 | 70 | kwargs['order'] = self.pk 71 | kwargs['reference'] = reference 72 | 73 | return SalesOrderShipment.create(self._api, data=kwargs) 74 | 75 | def issue(self, **kwargs): 76 | """Issue (send) this order""" 77 | return self._statusupdate(status='issue', **kwargs) 78 | 79 | def hold(self, **kwargs): 80 | """Place this order on hold""" 81 | return self._statusupdate(status='hold', **kwargs) 82 | 83 | def cancel(self, **kwargs): 84 | """Cancel this order""" 85 | return self._statusupdate(status='cancel', **kwargs) 86 | 87 | 88 | class SalesOrderLineItem( 89 | inventree.base.InventreeObject, 90 | inventree.base.MetadataMixin, 91 | ): 92 | """ Class representing the SalesOrderLineItem database model """ 93 | 94 | URL = 'order/so-line' 95 | 96 | def getPart(self): 97 | """ 98 | Return the Part object referenced by this SalesOrderLineItem 99 | """ 100 | return inventree.part.Part(self._api, self.part) 101 | 102 | def getOrder(self): 103 | """ 104 | Return the SalesOrder to which this SalesOrderLineItem belongs 105 | """ 106 | return SalesOrder(self._api, self.order) 107 | 108 | def allocateToShipment(self, shipment, stockitems=None, quantity=None): 109 | """ 110 | Assign the items of this line to the given shipment. 111 | 112 | By default, assign the total quantity using the first stock 113 | item(s) found. As many items as possible, up to the quantity in 114 | sales order, are assigned. 115 | 116 | To limit which stock items can be used, supply a list of stockitems 117 | to use in the argument stockitems. 118 | 119 | To limit how many items are assigned, supply a quantity to the 120 | argument quantity. This can also be used to over-assign the items, 121 | as no check for the amounts in the sales order is performed. 122 | 123 | This function returns a list of items assigned during this call. 124 | If nothing is returned, this means that nothing was assigned, 125 | possibly because no stock items are available. 126 | """ 127 | 128 | # If stockitems are not defined, get the list of available stock items 129 | if stockitems is None: 130 | stockitems = self.getPart().getStockItems(include_variants=False, in_stock=True, available=True) 131 | 132 | # If no quantity is defined, calculate the number of required items 133 | # This is the number of sold items not yet allocated, but can not 134 | # be higher than the number of allocated items 135 | if quantity is None: 136 | required_amount = min( 137 | self.quantity - self.allocated, self.available_stock 138 | ) 139 | 140 | else: 141 | try: 142 | required_amount = int(quantity) 143 | except ValueError: 144 | raise ValueError( 145 | "Argument quantity must be convertible to an integer" 146 | ) 147 | 148 | # Look through stock items, assign items until the required amount 149 | # is reached 150 | items = list() 151 | 152 | for SI in stockitems: 153 | 154 | # Check if we are done 155 | if required_amount <= 0: 156 | continue 157 | 158 | # Check that this item has available stock 159 | if SI.quantity - SI.allocated > 0: 160 | thisitem = { 161 | "line_item": self.pk, 162 | "quantity": min( 163 | required_amount, SI.quantity - SI.allocated 164 | ), 165 | "stock_item": SI.pk 166 | } 167 | 168 | # Correct the required amount 169 | required_amount -= thisitem["quantity"] 170 | 171 | # Append 172 | items.append(thisitem) 173 | 174 | # Use SalesOrderShipment method to perform allocation 175 | if len(items) > 0: 176 | return shipment.allocateItems(items) 177 | 178 | 179 | class SalesOrderExtraLineItem( 180 | inventree.base.InventreeObject, 181 | inventree.base.MetadataMixin, 182 | ): 183 | """ Class representing the SalesOrderExtraLineItem database model """ 184 | 185 | URL = 'order/so-extra-line' 186 | 187 | def getOrder(self): 188 | """ 189 | Return the SalesOrder to which this SalesOrderLineItem belongs 190 | """ 191 | return SalesOrder(self._api, self.order) 192 | 193 | 194 | class SalesOrderAllocation( 195 | inventree.base.InventreeObject 196 | ): 197 | """Class representing the SalesOrderAllocation database model.""" 198 | 199 | MIN_API_VERSION = 267 200 | URL = 'order/so-allocation' 201 | 202 | def getOrder(self): 203 | """Return the SalesOrder to which this SalesOrderAllocation belongs.""" 204 | return SalesOrder(self._api, self.order) 205 | 206 | def getShipment(self): 207 | """Return the SalesOrderShipment to which this SalesOrderAllocation belongs.""" 208 | from sales_order import SalesOrderShipment 209 | return SalesOrderShipment(self._api, self.shipment) 210 | 211 | def getLineItem(self): 212 | """Return the SalesOrderLineItem to which this SalesOrderAllocation belongs.""" 213 | return SalesOrderLineItem(self._api, self.line) 214 | 215 | def getStockItem(self): 216 | """Return the StockItem to which this SalesOrderAllocation belongs.""" 217 | return inventree.stock.StockItem(self._api, self.item) 218 | 219 | def getPart(self): 220 | """Return the Part to which this SalesOrderAllocation belongs.""" 221 | return inventree.part.Part(self._api, self.part) 222 | 223 | 224 | class SalesOrderShipment( 225 | inventree.base.InventreeObject, 226 | inventree.base.StatusMixin, 227 | inventree.base.MetadataMixin, 228 | ): 229 | """Class representing a shipment for a SalesOrder""" 230 | 231 | URL = 'order/so/shipment' 232 | 233 | def getOrder(self): 234 | """Return the SalesOrder to which this SalesOrderShipment belongs.""" 235 | return SalesOrder(self._api, self.order) 236 | 237 | def allocateItems(self, items=[]): 238 | """ 239 | Function to allocate items to the current shipment 240 | 241 | items is expected to be a list containing dicts, one for each item 242 | to be assigned. Each dict should contain three parameters, as 243 | follows: 244 | items = [{ 245 | "line_item": 25, 246 | "quantity": 150, 247 | "stock_item": 26 248 | } 249 | """ 250 | 251 | # Customize URL 252 | url = f'order/so/{self.getOrder().pk}/allocate' 253 | 254 | # Create data from given inputs 255 | data = { 256 | 'items': items, 257 | 'shipment': self.pk 258 | } 259 | 260 | # Send data 261 | response = self._api.post(url, data) 262 | 263 | # Reload 264 | self.reload() 265 | 266 | # Return 267 | return response 268 | 269 | def getAllocations(self): 270 | """Return the allocations associated with this shipment""" 271 | return SalesOrderAllocation.list(self._api, shipment=self.pk) 272 | 273 | @property 274 | def allocations(self): 275 | """Return the allocations associated with this shipment. 276 | 277 | Note: This is an overload of getAllocations() method, for legacy compatibility. 278 | """ 279 | try: 280 | return self.getAllocations() 281 | except NotImplementedError: 282 | return self._data['allocations'] 283 | 284 | def complete( 285 | self, 286 | shipment_date=None, 287 | tracking_number='', 288 | invoice_number='', 289 | link='', 290 | **kwargs 291 | ): 292 | """ 293 | Complete the shipment, with given shipment_date, or reasonable 294 | defaults. 295 | """ 296 | 297 | # Create data from given inputs 298 | data = { 299 | 'shipment_date': shipment_date, 300 | 'tracking_number': tracking_number, 301 | 'invoice_number': invoice_number, 302 | 'link': link 303 | } 304 | 305 | # Return 306 | return self._statusupdate(status='ship', data=data, **kwargs) 307 | 308 | def ship(self, *args, **kwargs): 309 | """Alias for complete function""" 310 | self.complete(*args, **kwargs) 311 | -------------------------------------------------------------------------------- /inventree/stock.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import os 5 | 6 | import inventree.api 7 | import inventree.base 8 | import inventree.company 9 | import inventree.label 10 | import inventree.part 11 | import inventree.report 12 | 13 | 14 | class StockLocation( 15 | inventree.base.BarcodeMixin, 16 | inventree.base.MetadataMixin, 17 | inventree.label.LabelPrintingMixin, 18 | inventree.report.ReportPrintingMixin, 19 | inventree.base.InventreeObject, 20 | ): 21 | """ Class representing the StockLocation database model """ 22 | 23 | URL = 'stock/location' 24 | MODEL_TYPE = 'stocklocation' 25 | 26 | def getStockItems(self, **kwargs): 27 | return StockItem.list(self._api, location=self.pk, **kwargs) 28 | 29 | def getParentLocation(self): 30 | """ 31 | Return the parent stock location 32 | (or None if no parent is available) 33 | """ 34 | 35 | if self.parent is None: 36 | return None 37 | 38 | return StockLocation(self._api, pk=self.parent) 39 | 40 | def getChildLocations(self, **kwargs): 41 | """ 42 | Return all the child locations under this location 43 | """ 44 | return StockLocation.list(self._api, parent=self.pk, **kwargs) 45 | 46 | 47 | class StockItem( 48 | inventree.base.AttachmentMixin, 49 | inventree.base.BarcodeMixin, 50 | inventree.base.BulkDeleteMixin, 51 | inventree.base.MetadataMixin, 52 | inventree.label.LabelPrintingMixin, 53 | inventree.base.InventreeObject, 54 | ): 55 | """Class representing the StockItem database model.""" 56 | 57 | URL = 'stock' 58 | 59 | MODEL_TYPE = 'stockitem' 60 | 61 | @classmethod 62 | def adjustStockItems(cls, api: inventree.api.InvenTreeAPI, method: str, items: list, **kwargs): 63 | """Perform a generic stock 'adjustment' action. 64 | 65 | Arguments: 66 | api: InvenTreeAPI instance 67 | method: Adjument method, e.g. 'count' / 'add' 68 | items: List of items to include in the adjustment (see below) 69 | kwargs: Additional kwargs to send with the adjustment 70 | 71 | Items: 72 | Each 'item' in the 'items' list must be a dict object, containing the following fields: 73 | 74 | pk: The 'pk' (primary key) identifier for a StockItem instance 75 | quantity: The quantity of each stock item for the particular action 76 | """ 77 | 78 | if method not in ['count', 'add', 'remove', 'transfer', 'assign']: 79 | raise ValueError(f"Stock adjustment method '{method}' not supported") 80 | 81 | url = f"stock/{method}/" 82 | 83 | data = kwargs 84 | data['items'] = items 85 | 86 | return api.post(url, data=data) 87 | 88 | @classmethod 89 | def countStockItems(cls, api: inventree.api.InvenTreeAPI, items: list, **kwargs): 90 | """Perform 'count' adjustment for multiple stock items""" 91 | 92 | return cls.adjustStockItems( 93 | api, 94 | 'count', 95 | items, 96 | **kwargs 97 | ) 98 | 99 | @classmethod 100 | def addStockItems(cls, api: inventree.api.InvenTreeAPI, items: list, **kwargs): 101 | """Perform 'add' adjustment for multiple stock items""" 102 | 103 | return cls.adjustStockItems( 104 | api, 105 | 'add', 106 | items, 107 | **kwargs 108 | ) 109 | 110 | @classmethod 111 | def removeStockItems(cls, api: inventree.api.InvenTreeAPI, items: list, **kwargs): 112 | """Perform 'remove' adjustment for multiple stock items""" 113 | 114 | return cls.adjustStockItems( 115 | api, 116 | 'remove', 117 | items, 118 | **kwargs 119 | ) 120 | 121 | @classmethod 122 | def transferStockItems(cls, api: inventree.api.InvenTreeAPI, items: list, location: int, **kwargs): 123 | """Perform 'transfer' adjustment for multiple stock items""" 124 | 125 | kwargs['location'] = location 126 | 127 | return cls.adjustStockItems( 128 | api, 129 | 'transfer', 130 | items, 131 | **kwargs 132 | ) 133 | 134 | @classmethod 135 | def assignStockItems(cls, api: inventree.api.InvenTreeAPI, items: list, customer: int, **kwargs): 136 | """Perform 'assign' adjustment for multiple stock items""" 137 | 138 | kwargs['customer'] = customer 139 | 140 | return cls.adjustStockItems( 141 | api, 142 | 'assign', 143 | items, 144 | **kwargs 145 | ) 146 | 147 | def countStock(self, quantity, **kwargs): 148 | """Perform a count (stocktake) action for this StockItem""" 149 | 150 | self.countStockItems( 151 | self._api, 152 | [ 153 | { 154 | 'pk': self.pk, 155 | 'quantity': quantity, 156 | } 157 | ], 158 | **kwargs 159 | ) 160 | 161 | def addStock(self, quantity, **kwargs): 162 | """Manually add the specified quantity to this StockItem""" 163 | 164 | self.addStockItems( 165 | self._api, 166 | [ 167 | { 168 | 'pk': self.pk, 169 | 'quantity': quantity, 170 | } 171 | ], 172 | **kwargs 173 | ) 174 | 175 | def removeStock(self, quantity, **kwargs): 176 | """Manually remove the specified quantity to this StockItem""" 177 | 178 | self.removeStockItems( 179 | self._api, 180 | [ 181 | { 182 | 'pk': self.pk, 183 | 'quantity': quantity, 184 | } 185 | ], 186 | **kwargs 187 | ) 188 | 189 | def transferStock(self, location, quantity=None, **kwargs): 190 | """Transfer this StockItem into the specified location. 191 | 192 | Arguments: 193 | location: A StockLocation instance or integer ID value 194 | quantity: Optionally specify quantity to transfer. If None, entire quantity is transferred 195 | notes: Optional transaction notes 196 | """ 197 | 198 | if isinstance(location, StockLocation): 199 | location = location.pk 200 | 201 | if quantity is None: 202 | quantity = self.quantity 203 | 204 | self.transferStockItems( 205 | self._api, 206 | [ 207 | { 208 | 'pk': self.pk, 209 | 'quantity': quantity, 210 | } 211 | ], 212 | location=location, 213 | **kwargs 214 | ) 215 | 216 | def assignStock(self, customer, **kwargs): 217 | """Assign this stock item to a customer (by company PK) 218 | 219 | Arguments: 220 | customer: A Company instance or integer ID value 221 | notes: Optional transaction notes""" 222 | 223 | if isinstance(customer, inventree.company.Company): 224 | customer = customer.pk 225 | 226 | self.assignStockItems( 227 | self._api, 228 | [ 229 | { 230 | 'item': self.pk, # In assign API, item is used instead of item 231 | } 232 | ], 233 | customer=customer, 234 | **kwargs 235 | ) 236 | 237 | def installStock(self, item, **kwargs): 238 | """Install the given item into this stock item 239 | 240 | Arguments: 241 | stockItem: A stockItem instance or integer ID value 242 | 243 | kwargs: 244 | quantity: quantity of installed items 245 | notes: Optional transaction notes""" 246 | 247 | if isinstance(item, StockItem): 248 | quantity = kwargs.get('quantity', item.quantity) 249 | item = item.pk 250 | else: 251 | quantity = kwargs.get('quantity', 1) 252 | 253 | kwargs['quantity'] = kwargs.get('quantity', quantity) 254 | kwargs['stock_item'] = item 255 | 256 | url = f"stock/{self.pk}/install/" 257 | 258 | return self._api.post(url, data=kwargs) 259 | 260 | def uninstallStock(self, location, quantity=1, **kwargs): 261 | """Uninstalls this item from any stock item 262 | 263 | Arguments: 264 | location: A StockLocation instance or integer ID value 265 | quantity: quantity of removed items. defaults to 1. 266 | notes: Optional transaction notes""" 267 | 268 | if isinstance(location, StockLocation): 269 | location = location.pk 270 | kwargs['quantity'] = quantity 271 | kwargs['stock_item'] = self.pk 272 | kwargs['location'] = location 273 | 274 | url = f"stock/{self.pk}/uninstall/" 275 | 276 | return self._api.post(url, data=kwargs) 277 | 278 | def getPart(self): 279 | """ Return the base Part object associated with this StockItem """ 280 | return inventree.part.Part(self._api, self.part) 281 | 282 | def getLocation(self): 283 | """ 284 | Return the StockLocation associated with this StockItem 285 | 286 | Returns None if there is no linked StockItem 287 | """ 288 | 289 | if self.location is None: 290 | return None 291 | 292 | return StockLocation(self._api, self.location) 293 | 294 | def getTrackingEntries(self, **kwargs): 295 | """Return list of StockItemTracking instances associated with this StockItem""" 296 | 297 | return StockItemTracking.list(self._api, item=self.pk, **kwargs) 298 | 299 | def getTestResults(self, **kwargs): 300 | """ Return all the test results associated with this StockItem """ 301 | 302 | return StockItemTestResult.list(self._api, stock_item=self.pk, **kwargs) 303 | 304 | def uploadTestResult(self, test_name, test_result, **kwargs): 305 | """ Upload a test result against this StockItem """ 306 | 307 | return StockItemTestResult.upload_result(self._api, self.pk, test_name, test_result, **kwargs) 308 | 309 | 310 | class StockItemTracking(inventree.base.InventreeObject): 311 | """Class representing a StockItem tracking object.""" 312 | 313 | URL = 'stock/track' 314 | 315 | 316 | class StockItemTestResult( 317 | inventree.base.BulkDeleteMixin, 318 | inventree.base.MetadataMixin, 319 | inventree.report.ReportPrintingMixin, 320 | inventree.base.InventreeObject, 321 | ): 322 | """Class representing a StockItemTestResult object. 323 | 324 | Note: From API version 169 and onwards, the StockItemTestResult object 325 | must reference a PartTestTemplate object, rather than a test name. 326 | 327 | However, for backwards compatibility, test results can be uploaded using the test name, 328 | and will be associated with the correct PartTestTemplate on the server. 329 | """ 330 | 331 | URL = 'stock/test' 332 | MODEL_TYPE = 'stockitem' 333 | 334 | def getTestTemplate(self): 335 | '''Return the PartTestTemplate item for this StockItemTestResult''' 336 | 337 | return inventree.part.PartTestTemplate(self._api, self.template) 338 | 339 | def getTestKey(self): 340 | 341 | # Get the test key from the PartTestTemplate 342 | return self.getTestTemplate().getTestKey() 343 | 344 | @classmethod 345 | def upload_result(cls, api, stock_item, test, result, **kwargs): 346 | """ 347 | Upload a test result. 348 | 349 | args: 350 | api: Authenticated InvenTree API object 351 | stock_item: pk of the StockItem object to upload the test result against 352 | test: Name of the test (string) or ID of the test template (integer) 353 | result: Test result (boolean) 354 | 355 | kwargs: 356 | attachment: Optionally attach a file to the test result 357 | notes: Add extra notes 358 | value: Add a "value" to the test (e.g. an actual measurement made during the test) 359 | """ 360 | 361 | attachment = kwargs.get('attachment', None) 362 | 363 | files = {} 364 | 365 | fo = None 366 | 367 | if attachment: 368 | if os.path.exists(attachment): 369 | f = os.path.basename(attachment) 370 | fo = open(attachment, 'rb') 371 | files['attachment'] = (f, fo) 372 | else: 373 | logging.error(f"File does not exist: '{attachment}'") 374 | 375 | notes = kwargs.get('notes', '') 376 | value = kwargs.get('value', '') 377 | 378 | data = { 379 | 'stock_item': stock_item, 380 | 'result': result, 381 | 'notes': notes, 382 | 'value': value, 383 | } 384 | 385 | # Determine the method by which the particular test is designated 386 | # It is either the test "name", or the test "template" ID 387 | if type(test) is int: 388 | data['template'] = test 389 | elif isinstance(test, inventree.part.PartTestTemplate): 390 | data['template'] = test.pk 391 | else: 392 | data['test'] = str(test) 393 | 394 | # Send the data to the server 395 | if api.post(cls.URL, data, files=files): 396 | logging.info(f"Uploaded test result: '{test}'") 397 | ret = True 398 | else: 399 | logging.warning("Test upload failed") 400 | ret = False 401 | 402 | # Ensure the file attachment is closed after use 403 | if fo: 404 | fo.close() 405 | 406 | return ret 407 | -------------------------------------------------------------------------------- /inventree/user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | import inventree.base 6 | 7 | logger = logging.getLogger('inventree') 8 | 9 | 10 | class User(inventree.base.InventreeObject): 11 | """ Class representing the User database model """ 12 | 13 | URL = 'user' 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 75.3.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "inventree" 7 | dynamic = ["version"] 8 | description = "Python interface for InvenTree inventory management system" 9 | readme = "README.md" 10 | license = { file = "LICENSE" } 11 | requires-python = ">=3.9" 12 | authors = [ 13 | { name = "Oliver Walters", email = "oliver.henry.walters@gmail.com" }, 14 | ] 15 | keywords = [ 16 | "barcode", 17 | "bill", 18 | "bom", 19 | "inventory", 20 | "management", 21 | "materials", 22 | "of", 23 | "stock", 24 | ] 25 | dependencies = [ 26 | "pip-system-certs>=4.0", 27 | "requests>=2.27.0", 28 | "urllib3>=2.3.0", 29 | ] 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/inventree/inventree-python/" 33 | 34 | [tool.setuptools.dynamic] 35 | version = {attr = "inventree.base.INVENTREE_PYTHON_VERSION"} 36 | 37 | [tool.setuptools] 38 | packages = ["inventree"] 39 | 40 | [tool.flake8] 41 | ignore =[ 42 | 'C901', 43 | # - W293 - blank lines contain whitespace 44 | 'W293', 45 | # - E501 - line too long (82 characters) 46 | 'E501', 47 | 'N802'] 48 | exclude = ['.git','__pycache__','inventree_server','dist','build','test.py'] 49 | max-complexity = 20 50 | 51 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests[socks]>=2.21.0 # Python HTTP for humans with proxy support 2 | flake8==7.1.2 # PEP checking 3 | flake8-pyproject==1.2.3 # PEP 621 support 4 | wheel>=0.34.2 # Building package 5 | invoke>=1.4.0 6 | coverage>=6.4.1 # Run tests, measure coverage 7 | coveralls>=3.3.1 8 | Pillow>=9.1.1 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | C901, 4 | # - W293 - blank lines contain whitespace 5 | W293, 6 | # - E501 - line too long (82 characters) 7 | E501 8 | N802 9 | exclude = .git,__pycache__,inventree_server,dist,build,test.py 10 | max-complexity = 20 11 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | try: 4 | from invoke import ctask as task 5 | except ImportError: 6 | from invoke import task 7 | 8 | import os 9 | import sys 10 | import time 11 | 12 | import requests 13 | from requests.auth import HTTPBasicAuth 14 | 15 | 16 | @task 17 | def style(c): 18 | """ 19 | Run PEP style checks against the codebase 20 | """ 21 | 22 | print("Running PEP code style checks...") 23 | c.run('flake8 .') 24 | 25 | 26 | @task 27 | def reset_data(c, debug=False): 28 | """ 29 | Reset the database to a known state. 30 | This is achieved by installing the InvenTree test fixture data. 31 | """ 32 | # Reset the database to a known state 33 | print("Reset test database to a known state (this might take a little while...)") 34 | 35 | hide = None if debug else 'both' 36 | 37 | c.run("docker-compose -f test/docker-compose.yml run --rm inventree-py-test-server invoke dev.delete-data -f", hide=hide) 38 | c.run("docker-compose -f test/docker-compose.yml run --rm inventree-py-test-server invoke migrate", hide=hide) 39 | c.run("docker-compose -f test/docker-compose.yml run --rm inventree-py-test-server invoke dev.import-fixtures", hide=hide) 40 | 41 | 42 | @task(post=[reset_data]) 43 | def update_image(c, debug=True, reset=True): 44 | """ 45 | Update the InvenTree image to the latest version 46 | """ 47 | 48 | print("Pulling latest InvenTree image from docker hub (maybe grab a coffee!)") 49 | 50 | hide = None if debug else 'both' 51 | 52 | c.run("docker-compose -f test/docker-compose.yml pull", hide=hide) 53 | c.run("docker-compose -f test/docker-compose.yml run --rm inventree-py-test-server invoke update --skip-backup --no-frontend --skip-static", hide=hide) 54 | 55 | if reset: 56 | reset_data(c) 57 | 58 | 59 | @task 60 | def check_server(c, host="http://localhost:12345", username="testuser", password="testpassword", debug=True, timeout=30): 61 | """ 62 | Check that we can ping the server and get a token. 63 | A generous timeout is applied here, to give the server time to activate 64 | """ 65 | 66 | auth = HTTPBasicAuth(username=username, password=password) 67 | 68 | url = f"{host}/api/user/token/" 69 | 70 | response = None 71 | 72 | while response is None: 73 | 74 | try: 75 | response = requests.get(url, auth=auth, timeout=0.5) 76 | except Exception as e: 77 | if debug: 78 | print("Error:", str(e)) 79 | 80 | if response is None: 81 | 82 | if timeout > 0: 83 | if debug: 84 | print(f"No response from server. {timeout} seconds remaining") 85 | timeout -= 1 86 | time.sleep(1) 87 | 88 | else: 89 | return False 90 | 91 | if response.status_code != 200: 92 | if debug: 93 | print(f"Invalid status code: ${response.status_code}") 94 | return False 95 | 96 | if 'token' not in response.text: 97 | if debug: 98 | print("Token not in returned response:") 99 | print(str(response.text)) 100 | return False 101 | 102 | # We have confirmed that the server is available 103 | if debug: 104 | print(f"InvenTree server is available at {host}") 105 | 106 | return True 107 | 108 | 109 | @task 110 | def start_server(c, debug=False): 111 | """ 112 | Launch the InvenTree server (in a docker container) 113 | """ 114 | 115 | # Start the InvenTree server 116 | print("Starting InvenTree server instance") 117 | c.run('docker-compose -f test/docker-compose.yml up -d', hide=None if debug else 'both') 118 | 119 | print("Waiting for InvenTree server to respond:") 120 | 121 | count = 60 122 | 123 | while not check_server(c, debug=False) and count > 0: 124 | count -= 1 125 | time.sleep(1) 126 | 127 | if count == 0: 128 | print("No response from InvenTree server") 129 | sys.exit(1) 130 | else: 131 | print("InvenTree server is active!") 132 | 133 | 134 | @task 135 | def stop_server(c, debug=False): 136 | """ 137 | Stop a running InvenTree test server docker container 138 | """ 139 | 140 | # Stop the server 141 | c.run('docker-compose -f test/docker-compose.yml down', hide=None if debug else 'both') 142 | 143 | 144 | @task(help={ 145 | 'source': 'Specify the source file to test', 146 | 'update': 'If set, update the docker image before running tests', 147 | 'reset': 'If set, reset test data to a known state', 148 | 'host': 'Specify the InvenTree host address (default = http://localhost:12345)', 149 | 'username': 'Specify the InvenTree username (default = testuser)', 150 | 'password': 'Specify the InvenTree password (default = testpassword)', 151 | 'noserver': 'If set, do not spin up the docker container', 152 | } 153 | ) 154 | def test(c, source=None, update=False, reset=False, debug=False, host=None, username=None, password=None, noserver=False): 155 | """ 156 | Run the unit tests for the python bindings. 157 | Performs the following steps: 158 | 159 | - Ensure the docker container is up and running 160 | - Reset the database to a known state (if --reset flag is given) 161 | - Perform unit testing 162 | """ 163 | 164 | # If a source file is provided, check that it actually exists 165 | if source: 166 | 167 | if not source.endswith('.py'): 168 | source += '.py' 169 | 170 | if not os.path.exists(source): 171 | source = os.path.join('test', source) 172 | 173 | if not os.path.exists(source): 174 | print(f"Error: Source file '{source}' does not exist") 175 | sys.exit(1) 176 | 177 | if not noserver: 178 | if update: 179 | # Pull down the latest InvenTree docker image 180 | update_image(c, debug=debug) 181 | 182 | if reset: 183 | stop_server(c, debug=debug) 184 | reset_data(c, debug=debug) 185 | 186 | # Launch the InvenTree server (in a docker container) 187 | start_server(c) 188 | 189 | # Set environment variables so test scripts can access them 190 | os.environ['INVENTREE_PYTHON_TEST_SERVER'] = host or 'http://localhost:12345' 191 | os.environ['INVENTREE_PYTHON_TEST_USERNAME'] = username or 'testuser' 192 | os.environ['INVENTREE_PYTHON_TEST_PASSWORD'] = password or 'testpassword' 193 | 194 | # Run unit tests 195 | 196 | # If a single source file is supplied, test *just* that file 197 | # Otherwise, test *all* files 198 | if source: 199 | print(f"Running tests for '{source}'") 200 | c.run(f'coverage run -m unittest {source}') 201 | else: 202 | # Automatically discover tests, and run only those 203 | c.run('coverage run -m unittest discover -s test/') 204 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inventree/inventree-python/71d844565503d67ef9475fcd2b77494e325e7272/test/__init__.py -------------------------------------------------------------------------------- /test/attachment.txt: -------------------------------------------------------------------------------- 1 | A demo file for uploading as a file attachment. -------------------------------------------------------------------------------- /test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Docker compose recipe for spawning a simple InvenTree server instance, 2 | # to use for running local tests of the InvenTree python API 3 | # We use the latest (master branch) InvenTree code for testing. 4 | 5 | # The tests should be targeted at localhost:12345 6 | 7 | services: 8 | 9 | inventree-py-test-server: 10 | container_name: inventree-py-test-server 11 | image: inventree/inventree:latest 12 | ports: 13 | # Expose internal port 8000 on external port 12345 14 | - 12345:8000 15 | environment: 16 | - INVENTREE_DEBUG=True 17 | - INVENTREE_SITE_URL=http://localhost:12345 18 | - INVENTREE_DB_ENGINE=sqlite 19 | - INVENTREE_DB_NAME=/home/inventree/data/test_db.sqlite3 20 | - INVENTREE_DEBUG_LEVEL=error 21 | - INVENTREE_ADMIN_USER=testuser 22 | - INVENTREE_ADMIN_PASSWORD=testpassword 23 | - INVENTREE_ADMIN_EMAIL=test@test.com 24 | - INVENTREE_COOKIE_SAMESITE=False 25 | restart: unless-stopped 26 | volumes: 27 | - ./data:/home/inventree/data 28 | -------------------------------------------------------------------------------- /test/dummytemplate.html: -------------------------------------------------------------------------------- 1 | {% extends "label/label_base.html" %} 2 | 3 |
TEST LABEL
4 | 5 | {% endblock content %} 6 | -------------------------------------------------------------------------------- /test/dummytemplate2.html: -------------------------------------------------------------------------------- 1 | {% extends "label/label_base.html" %} 2 | 3 |
TEST LABEL TWO
4 | 5 | {% endblock content %} 6 | -------------------------------------------------------------------------------- /test/test_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | import unittest 6 | 7 | import requests 8 | 9 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 10 | 11 | from inventree import api # noqa: E402 12 | from inventree import base # noqa: E402 13 | from inventree import part # noqa: E402 14 | from inventree import stock # noqa: E402 15 | 16 | SERVER = os.environ.get('INVENTREE_PYTHON_TEST_SERVER', 'http://127.0.0.1:12345') 17 | USERNAME = os.environ.get('INVENTREE_PYTHON_TEST_USERNAME', 'testuser') 18 | PASSWORD = os.environ.get('INVENTREE_PYTHON_TEST_PASSWORD', 'testpassword') 19 | 20 | 21 | class URLTests(unittest.TestCase): 22 | """Class for testing URL functionality""" 23 | 24 | def test_base_url(self): 25 | """Test validation of URL provided to InvenTreeAPI class""" 26 | 27 | # Each of these URLs should be invalid 28 | for url in [ 29 | "test.com/123", 30 | "http://:80/123", 31 | "//xyz.co.uk", 32 | ]: 33 | with self.assertRaises(Exception): 34 | a = api.InvenTreeAPI(url, connect=False) 35 | 36 | # test for base URL construction 37 | a = api.InvenTreeAPI('https://test.com', connect=False) 38 | self.assertEqual(a.base_url, 'https://test.com/') 39 | self.assertEqual(a.api_url, 'https://test.com/api/') 40 | 41 | # more tests that the base URL is set correctly under specific conditions 42 | urls = [ 43 | "http://a.b.co:80/sub/dir/api/", 44 | "http://a.b.co:80/sub/dir/api", 45 | "http://a.b.co:80/sub/dir/", 46 | "http://a.b.co:80/sub/dir", 47 | ] 48 | 49 | for url in urls: 50 | a = api.InvenTreeAPI(url, connect=False) 51 | self.assertEqual(a.base_url, "http://a.b.co:80/sub/dir/") 52 | self.assertEqual(a.api_url, "http://a.b.co:80/sub/dir/api/") 53 | 54 | def test_url_construction(self): 55 | """Test that the API class correctly constructs URLs""" 56 | 57 | a = api.InvenTreeAPI("http://localhost:1234", connect=False) 58 | 59 | tests = { 60 | 'part': 'http://localhost:1234/api/part/', 61 | '/part': 'http://localhost:1234/api/part/', 62 | '/part/': 'http://localhost:1234/api/part/', 63 | 'order/so/shipment': 'http://localhost:1234/api/order/so/shipment/', 64 | } 65 | 66 | for endpoint, url in tests.items(): 67 | self.assertEqual(a.constructApiUrl(endpoint), url) 68 | 69 | 70 | class LoginTests(unittest.TestCase): 71 | 72 | def test_failed_logins(self): 73 | 74 | # Attempt connection where no server exists 75 | with self.assertRaises(Exception): 76 | a = api.InvenTreeAPI("http://127.0.0.1:9999", username="admin", password="password") 77 | 78 | # Attempt connection with invalid credentials 79 | with self.assertRaises(Exception): 80 | a = api.InvenTreeAPI(SERVER, username="abcde", password="********") 81 | 82 | self.assertIsNotNone(a.server_details) 83 | self.assertIsNone(a.token) 84 | 85 | 86 | class Unauthenticated(unittest.TestCase): 87 | """ 88 | Test that we cannot access the data if we are not authenticated. 89 | """ 90 | 91 | def setUp(self): 92 | self.api = api.InvenTreeAPI(SERVER, username="hello", password="world", connect=False) 93 | 94 | def test_read_parts(self): 95 | 96 | with self.assertRaises(Exception) as ar: 97 | part.Part.list(self.api) 98 | 99 | self.assertIn('Authentication at InvenTree server failed', str(ar.exception)) 100 | 101 | def test_file_download(self): 102 | """ 103 | Attempting to download a file while unauthenticated should raise an error 104 | """ 105 | 106 | # Downloading without auth = unauthorized error (401) 107 | with self.assertRaises(requests.exceptions.HTTPError): 108 | self.assertFalse(self.api.downloadFile('/media/part/files/1/test.pdf', 'test.pdf')) 109 | 110 | 111 | class Timeout(unittest.TestCase): 112 | """ 113 | Test that short timeout leads to correct error 114 | """ 115 | 116 | def test_timeout(self): 117 | """ 118 | This unrealistically short timeout should lead to a timeout error 119 | """ 120 | # Attempt connection with short timeout 121 | with self.assertRaises(requests.exceptions.ReadTimeout): 122 | a = api.InvenTreeAPI(SERVER, username=USERNAME, password=PASSWORD, timeout=0.001) # noqa: F841 123 | 124 | 125 | class InvenTreeTestCase(unittest.TestCase): 126 | """ 127 | Base class for running InvenTree unit tests. 128 | 129 | - Creates an authenticated API instance 130 | """ 131 | 132 | def setUp(self): 133 | """ 134 | Test case setup functions 135 | """ 136 | self.api = api.InvenTreeAPI( 137 | SERVER, 138 | username=USERNAME, password=PASSWORD, 139 | timeout=30, 140 | token_name='python-test', 141 | use_token_auth=True 142 | ) 143 | 144 | 145 | class InvenTreeAPITest(InvenTreeTestCase): 146 | 147 | def test_token(self): 148 | self.assertIsNotNone(self.api.token) 149 | 150 | def test_details(self): 151 | self.assertIsNotNone(self.api.server_details) 152 | 153 | details = self.api.server_details 154 | self.assertIn('server', details) 155 | self.assertIn('instance', details) 156 | 157 | self.assertIn('apiVersion', details) 158 | 159 | api_version = int(details['apiVersion']) 160 | 161 | self.assertTrue(api_version >= self.api.getMinApiVersion()) 162 | 163 | 164 | class TestCreate(InvenTreeTestCase): 165 | """ 166 | Test that objects can be created via the API 167 | """ 168 | 169 | def test_create_stuff(self): 170 | 171 | with self.assertRaises(requests.exceptions.ReadTimeout): 172 | # Test short timeout for a specific function 173 | c = part.PartCategory.create(self.api, { 174 | 'parent': None, 175 | 'name': 'My custom category', 176 | 'description': 'A part category', 177 | }, timeout=0.001) 178 | 179 | n = part.PartCategory.count(self.api) 180 | 181 | # Create a custom category 182 | c = part.PartCategory.create(self.api, { 183 | 'parent': None, 184 | 'name': f'Custom category {n + 1}', 185 | 'description': 'A part category', 186 | }) 187 | 188 | self.assertIsNotNone(c) 189 | self.assertIsNotNone(c.pk) 190 | 191 | n = part.Part.count(self.api) 192 | 193 | p = part.Part.create(self.api, { 194 | 'name': f'ACME Widget {n}', 195 | 'description': 'A simple widget created via the API', 196 | 'category': c.pk, 197 | 'ipn': f'ACME-0001-{n}', 198 | 'virtual': False, 199 | 'active': True 200 | }) 201 | 202 | self.assertIsNotNone(p) 203 | self.assertEqual(p.category, c.pk) 204 | 205 | cat = p.getCategory() 206 | self.assertEqual(cat.pk, c.pk) 207 | 208 | s = stock.StockItem.create(self.api, { 209 | 'part': p.pk, 210 | 'quantity': 45, 211 | 'notes': 'This is a note', 212 | 213 | }) 214 | 215 | self.assertIsNotNone(s) 216 | self.assertEqual(s.part, p.pk) 217 | 218 | prt = s.getPart() 219 | self.assertEqual(prt.pk, p.pk) 220 | self.assertEqual(prt.name, f'ACME Widget {n}') 221 | 222 | 223 | class TemplateTest(InvenTreeTestCase): 224 | 225 | def test_get_widget(self): 226 | 227 | widget = part.Part(self.api, 10000) 228 | 229 | self.assertEqual(widget.name, "Chair Template") 230 | 231 | test_templates = widget.getTestTemplates() 232 | self.assertGreaterEqual(len(test_templates), 5) 233 | 234 | keys = [test.key for test in test_templates] 235 | 236 | self.assertIn('teststrengthofchair', keys) 237 | self.assertIn('applypaint', keys) 238 | self.assertIn('attachlegs', keys) 239 | 240 | def test_add_template(self): 241 | """ 242 | Test that we can add a test template via the API 243 | """ 244 | 245 | widget = part.Part(self.api, pk=10000) 246 | 247 | n = len(widget.getTestTemplates()) 248 | 249 | part.PartTestTemplate.create(self.api, { 250 | 'part': widget.pk, 251 | 'test_name': f"Test_Name_{n}", 252 | 'description': 'A test or something', 253 | 'required': True, 254 | }) 255 | 256 | self.assertEqual(len(widget.getTestTemplates()), n + 1) 257 | 258 | def test_add_result(self): 259 | 260 | # Look for a particular serial number 261 | items = stock.StockItem.list(self.api, serial=1000) 262 | self.assertEqual(len(items), 1) 263 | 264 | item = items[0] 265 | 266 | tests = item.getTestResults() 267 | 268 | n = len(tests) 269 | 270 | # Upload a test result against 'firmware' (should fail the first time) 271 | args = { 272 | 'attachment': 'test/attachment.txt', 273 | 'value': '0x123456', 274 | } 275 | 276 | result = item.uploadTestResult('firmware', False, **args) 277 | 278 | self.assertTrue(result) 279 | 280 | item.uploadTestResult('paint', True) 281 | item.uploadTestResult('extra test', False, value='some data') 282 | 283 | # There should be 3 more test results now! 284 | results = item.getTestResults() 285 | self.assertEqual(len(results), n + 3) 286 | 287 | 288 | if __name__ == '__main__': 289 | print("Running InvenTree Python Unit Tests: Version " + base.INVENTREE_PYTHON_VERSION) 290 | unittest.main() 291 | -------------------------------------------------------------------------------- /test/test_base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit test for basic model class functionality 3 | """ 4 | 5 | import os 6 | import sys 7 | 8 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 9 | 10 | from test_api import InvenTreeTestCase # noqa: E402 11 | 12 | from inventree.base import InventreeObject # noqa: E402 13 | 14 | 15 | class BaseModelTests(InvenTreeTestCase): 16 | """Simple unit tests for the InvenTreeObject class""" 17 | 18 | def test_create(self): 19 | """Unit tests for InventreeObject creation""" 20 | 21 | # Test with non-int pk 22 | with self.assertRaises(TypeError): 23 | InventreeObject(None, pk='test') 24 | 25 | # Test with invalid pk 26 | for pk in [-1, 0]: 27 | with self.assertRaises(ValueError): 28 | InventreeObject(None, pk=pk) 29 | 30 | # Test with pk supplied in data 31 | with self.assertRaises(TypeError): 32 | InventreeObject( 33 | None, 34 | data={ 35 | 'pk': 'seven', 36 | } 37 | ) 38 | 39 | def test_data_access(self): 40 | """Test data access functionality""" 41 | 42 | obj = InventreeObject( 43 | None, 44 | data={ 45 | "pk": "10", 46 | "hello": "world", 47 | "name": "My name", 48 | "description": "My description", 49 | } 50 | ) 51 | 52 | # Test __getattr__ access 53 | self.assertEqual(obj.pk, 10) 54 | self.assertEqual(obj.name, "My name") 55 | 56 | with self.assertRaises(AttributeError): 57 | print(obj.doesNotExist) 58 | 59 | # Test __getitem__ access 60 | self.assertEqual(obj['description'], 'My description') 61 | self.assertEqual(obj['pk'], '10') 62 | self.assertEqual(obj['hello'], 'world') 63 | 64 | for k in ['fake', 'data', 'values']: 65 | with self.assertRaises(KeyError): 66 | print(obj[k]) 67 | -------------------------------------------------------------------------------- /test/test_build.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Unit test for the Build models 5 | """ 6 | 7 | import os 8 | import sys 9 | 10 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 11 | 12 | from test_api import InvenTreeTestCase # noqa: E402 13 | 14 | from inventree.base import Attachment # noqa: E402 15 | from inventree.build import Build # noqa: E402 16 | 17 | 18 | class BuildOrderTest(InvenTreeTestCase): 19 | """ 20 | Unit tests for Build model 21 | """ 22 | 23 | def get_build(self): 24 | """ 25 | Return a BuildOrder from the database 26 | If a build does not already exist, create a new one 27 | """ 28 | 29 | builds = Build.list(self.api) 30 | 31 | n = len(builds) 32 | 33 | if n == 0: 34 | # Create a new build order 35 | build = Build.create( 36 | self.api, 37 | { 38 | "title": "Automated test build", 39 | "part": 25, 40 | "quantity": 100, 41 | "reference": f"BO-{n+1:04d}", 42 | } 43 | ) 44 | else: 45 | build = builds[-1] 46 | 47 | return build 48 | 49 | def test_list_builds(self): 50 | 51 | build = self.get_build() 52 | 53 | self.assertIsNotNone(build) 54 | 55 | builds = Build.list(self.api) 56 | 57 | self.assertGreater(len(builds), 0) 58 | 59 | def test_build_attachment(self): 60 | """ 61 | Test that we can upload an attachment against a Build 62 | """ 63 | 64 | if self.api.api_version < Attachment.MIN_API_VERSION: 65 | return 66 | 67 | build = self.get_build() 68 | 69 | n = len(build.getAttachments()) 70 | 71 | # Upload *this* file 72 | fn = os.path.join(os.path.dirname(__file__), 'test_build.py') 73 | 74 | response = build.uploadAttachment(fn, comment='A self referencing upload!') 75 | 76 | self.assertEqual(response['model_type'], 'build') 77 | self.assertEqual(response['model_id'], build.pk) 78 | self.assertEqual(response['comment'], 'A self referencing upload!') 79 | 80 | self.assertEqual(len(build.getAttachments()), n + 1) 81 | 82 | def test_build_cancel(self): 83 | """ 84 | Test cancelling a build order. 85 | """ 86 | 87 | n = len(Build.list(self.api)) 88 | 89 | # Create a new build order 90 | build = Build.create( 91 | self.api, 92 | { 93 | "title": "Automated test build", 94 | "part": 25, 95 | "quantity": 100, 96 | "reference": f"BO-{n+1:04d}" 97 | } 98 | ) 99 | 100 | # Cancel 101 | build.cancel() 102 | 103 | # Check status 104 | self.assertEqual(build.status, 30) 105 | self.assertEqual(build.status_text, 'Cancelled') 106 | 107 | def test_build_complete(self): 108 | """ 109 | Test completing a build order. 110 | """ 111 | 112 | n = len(Build.list(self.api)) 113 | 114 | # Create a new build order 115 | build = Build.create( 116 | self.api, 117 | { 118 | "title": "Automated test build", 119 | "part": 25, 120 | "quantity": 100, 121 | "reference": f"BO-{n+1:04d}" 122 | } 123 | ) 124 | 125 | # Check that build status is pending 126 | self.assertEqual(build.status, 10) 127 | 128 | if self.api.api_version >= 233: 129 | # Issue the build order 130 | build.issue() 131 | self.assertEqual(build.status, 20) 132 | 133 | # Mark build order as "on hold" again 134 | build.hold() 135 | self.assertEqual(build.status, 25) 136 | 137 | # Issue again 138 | build.issue() 139 | self.assertEqual(build.status, 20) 140 | 141 | # Complete the build, even though it is not completed 142 | build.complete(accept_unallocated=True, accept_incomplete=True) 143 | 144 | # Check status 145 | self.assertEqual(build.status, 40) 146 | self.assertEqual(build.status_text, 'Complete') 147 | -------------------------------------------------------------------------------- /test/test_company.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | 6 | from requests.exceptions import HTTPError 7 | 8 | try: 9 | import Image 10 | except ImportError: 11 | from PIL import Image 12 | 13 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 14 | 15 | from test_api import InvenTreeTestCase # noqa: E402 16 | 17 | from inventree.base import Attachment # noqa: E402 18 | from inventree import company # noqa: E402 19 | from inventree.part import Part # noqa: E402 20 | 21 | 22 | class ContactTest(InvenTreeTestCase): 23 | """Tests for the 'Contact' model""" 24 | 25 | def test_contact_create(self): 26 | """Test that we can create a new contact""" 27 | 28 | cmp = company.Company.list(self.api, limit=1)[0] 29 | n = company.Contact.count(self.api) 30 | 31 | for idx in range(3): 32 | company.Contact.create(self.api, data={ 33 | 'company': cmp.pk, 34 | 'name': f"Random Name {idx}", 35 | 'role': f"Random Role {idx}", 36 | }) 37 | 38 | self.assertEqual(company.Contact.count(self.api), n + 3) 39 | 40 | 41 | class CompanyTest(InvenTreeTestCase): 42 | """ 43 | Test that Company related objects can be managed via the API 44 | """ 45 | 46 | def test_fields(self): 47 | """ 48 | Test field names via OPTIONS request 49 | """ 50 | 51 | field_names = company.Company.fieldNames(self.api) 52 | 53 | for field in [ 54 | 'name', 55 | 'image', 56 | 'is_customer', 57 | 'is_manufacturer', 58 | 'is_supplier' 59 | ]: 60 | self.assertIn(field, field_names) 61 | 62 | def test_company_create(self): 63 | c = company.Company.create(self.api, { 64 | 'name': 'Company', 65 | }) 66 | 67 | self.assertIsNotNone(c) 68 | 69 | def test_company_parts(self): 70 | """ 71 | Tests that the 'supplied' and 'manufactured' parts can be retrieved 72 | """ 73 | 74 | c = company.Company.create(self.api, { 75 | 'name': 'MyTestCompany', 76 | 'description': 'A manufacturer *AND* a supplier', 77 | 'is_manufacturer': True, 78 | 'is_supplier': True, 79 | }) 80 | 81 | self.assertIsNotNone(c) 82 | 83 | self.assertEqual(len(c.getManufacturedParts()), 0) 84 | self.assertEqual(len(c.getSuppliedParts()), 0) 85 | 86 | # Create some 'manufactured parts' 87 | for i in range(3): 88 | 89 | mpn = f"MPN_XYX-{i}_{c.pk}" 90 | sku = f"SKU_ABC-{i}_{c.pk}" 91 | 92 | # Create a new ManufacturerPart 93 | m_part = company.ManufacturerPart.create(self.api, { 94 | 'part': 1, 95 | 'manufacturer': c.pk, 96 | 'MPN': mpn 97 | }) 98 | 99 | # Creating a unique SupplierPart should also create a ManufacturerPart 100 | company.SupplierPart.create(self.api, { 101 | 'supplier': c.pk, 102 | 'part': 1, 103 | 'manufacturer_part': m_part.pk, 104 | 'SKU': sku, 105 | }) 106 | 107 | self.assertEqual(len(c.getManufacturedParts()), 3) 108 | self.assertEqual(len(c.getSuppliedParts()), 3) 109 | 110 | def test_manufacturer_part_create(self): 111 | 112 | manufacturer = company.Company(self.api, 7) 113 | 114 | n = len(manufacturer.getManufacturedParts()) 115 | 116 | # Create a new manufacturer part with a unique name 117 | manufacturer_part = company.ManufacturerPart.create(self.api, { 118 | 'manufacturer': manufacturer.pk, 119 | 'MPN': f'MPN_TEST_{n}', 120 | 'part': 3, 121 | }) 122 | 123 | self.assertIsNotNone(manufacturer_part) 124 | self.assertEqual(manufacturer_part.manufacturer, manufacturer.pk) 125 | 126 | # Check that listing the manufacturer parts against this manufacturer has increased by 1 127 | man_parts = company.ManufacturerPart.list(self.api, manufacturer=manufacturer.pk) 128 | self.assertEqual(len(man_parts), n + 1) 129 | 130 | def test_manufacturer_part_parameters(self): 131 | """ 132 | Test that we can create, retrieve and edit ManufacturerPartParameter objects 133 | """ 134 | 135 | n = len(company.ManufacturerPart.list(self.api)) 136 | 137 | mpn = f"XYZ-12345678-{n}" 138 | 139 | # First, create a new ManufacturerPart 140 | part = company.ManufacturerPart.create(self.api, { 141 | 'manufacturer': 6, 142 | 'part': 1, 143 | 'MPN': mpn, 144 | }) 145 | 146 | self.assertIsNotNone(part) 147 | self.assertEqual(len(company.ManufacturerPart.list(self.api)), n + 1) 148 | 149 | # Part should (initially) not have any parameters 150 | self.assertEqual(len(part.getParameters()), 0) 151 | 152 | # Now, let's create some! 153 | for idx in range(10): 154 | 155 | parameter = company.ManufacturerPartParameter.create(self.api, { 156 | 'manufacturer_part': part.pk, 157 | 'name': f"param {idx}", 158 | 'value': f"{idx}", 159 | }) 160 | 161 | self.assertIsNotNone(parameter) 162 | 163 | # Now should have 10 unique parameters 164 | self.assertEqual(len(part.getParameters()), 10) 165 | 166 | # Attempt to create a duplicate parameter 167 | with self.assertRaises(HTTPError): 168 | company.ManufacturerPartParameter.create(self.api, { 169 | 'manufacturer_part': part.pk, 170 | 'name': 'param 0', 171 | 'value': 'some value', 172 | }) 173 | 174 | self.assertEqual(len(part.getParameters()), 10) 175 | 176 | # Test that we can edit a ManufacturerPartParameter 177 | parameter = part.getParameters()[0] 178 | 179 | self.assertEqual(parameter.value, '0') 180 | 181 | parameter['value'] = 'new value' 182 | parameter.save() 183 | 184 | self.assertEqual(parameter.value, 'new value') 185 | 186 | parameter['value'] = 'dummy value' 187 | parameter.reload() 188 | 189 | self.assertEqual(parameter.value, 'new value') 190 | 191 | # Test that the "list" function works correctly 192 | results = company.ManufacturerPartParameter.list(self.api) 193 | self.assertGreaterEqual(len(results), 10) 194 | 195 | results = company.ManufacturerPartParameter.list(self.api, name='param 1') 196 | self.assertGreaterEqual(len(results), 1) 197 | 198 | results = company.ManufacturerPartParameter.list(self.api, manufacturer_part=part.pk) 199 | self.assertGreaterEqual(len(results), 10) 200 | 201 | def test_supplier_part_create(self): 202 | """ 203 | Test that we can create SupplierPart objects via the API 204 | """ 205 | 206 | supplier = company.Company(self.api, 1) 207 | 208 | # Find a purchaseable part 209 | parts = Part.list(self.api, purchasable=True) 210 | 211 | if len(parts) > 0: 212 | prt = parts[0] 213 | else: 214 | prt = Part.create(self.api, { 215 | 'name': 'My purchaseable part', 216 | 'description': 'A purchasenable part we can use to make a SupplierPart', 217 | 'category': 1, 218 | 'purchaseable': True 219 | }) 220 | 221 | n = len(company.SupplierPart.list(self.api)) 222 | 223 | supplier_part = company.SupplierPart.create(self.api, { 224 | 'supplier': supplier.pk, 225 | 'SKU': f'SKU_TEST_{n}', 226 | 'part': prt.pk, 227 | }) 228 | 229 | self.assertIsNotNone(supplier_part) 230 | self.assertTrue(supplier_part.part, prt.pk) 231 | 232 | self.assertEqual(len(company.SupplierPart.list(self.api)), n + 1) 233 | 234 | def test_upload_company_image(self): 235 | """ 236 | Check we can upload image against a company 237 | """ 238 | 239 | # Grab the first company 240 | c = company.Company.list(self.api)[0] 241 | 242 | # Ensure the company does *not* have an image 243 | c.save(data={'image': None}) 244 | c.reload() 245 | 246 | self.assertIsNone(c.image) 247 | 248 | # Test that trying to *download* a null image results in failure! 249 | with self.assertRaises(ValueError): 250 | c.downloadImage("downloaded.png") 251 | 252 | # Now, let's actually upload a real image 253 | # Upload as an im-memory imgae file 254 | img = Image.new('RGB', (128, 128), color='blue') 255 | img.save('dummy_image.png') 256 | 257 | response = c.uploadImage('dummy_image.png') 258 | 259 | self.assertTrue(response) 260 | 261 | with self.assertRaises(FileNotFoundError): 262 | c.uploadImage('ddddummy_image.png') 263 | 264 | with self.assertRaises(TypeError): 265 | c.uploadImage(1) 266 | 267 | with self.assertRaises(TypeError): 268 | c.uploadImage(None) 269 | 270 | def test_attachments(self): 271 | """Unit tests for attachments.""" 272 | 273 | if self.api.api_version < Attachment.MIN_API_VERSION: 274 | return 275 | 276 | # Create a new manufacturer part, if one does not already exist 277 | mps = company.ManufacturerPart.list(self.api) 278 | 279 | if len(mps) > 0: 280 | mp = mps[0] 281 | else: 282 | mp = company.ManufacturerPart.create(self.api, { 283 | 'manufacturer': 7, 284 | 'part': 3, 285 | 'MPN': 'M7P3', 286 | }) 287 | 288 | # Initially, ensure there are no attachments associated with this part 289 | for attachment in mp.getAttachments(): 290 | attachment.delete() 291 | 292 | attachments = mp.getAttachments() 293 | 294 | self.assertTrue(len(attachments) == 0) 295 | 296 | # Let's create one! 297 | 298 | # Grab the first available manufacturer part 299 | 300 | response = mp.uploadAttachment( 301 | os.path.join(os.path.dirname(__file__), 'attachment.txt'), 302 | comment='Uploading a new manufacturer part attachment', 303 | ) 304 | 305 | self.assertIsNotNone(response) 306 | 307 | attachments = mp.getAttachments() 308 | 309 | self.assertEqual(len(attachments), 1) 310 | 311 | attachment = attachments[0] 312 | 313 | self.assertEqual(attachment['comment'], 'Uploading a new manufacturer part attachment') 314 | 315 | attachments = mp.getAttachments() 316 | 317 | self.assertEqual(len(attachments), 1) 318 | -------------------------------------------------------------------------------- /test/test_currency.py: -------------------------------------------------------------------------------- 1 | """Unit tests for currency exchange support""" 2 | 3 | 4 | import os 5 | import sys 6 | 7 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 8 | 9 | from test_api import InvenTreeTestCase # noqa: E402 10 | 11 | from inventree.currency import CurrencyManager # noqa: E402 12 | 13 | 14 | class CurrencyTest(InvenTreeTestCase): 15 | """Tests for currency support""" 16 | 17 | def test_fetch_data(self): 18 | """Test that we can fetch currency data from the server""" 19 | 20 | mgr = CurrencyManager(self.api) 21 | mgr.updateFromServer() 22 | 23 | self.assertIsNotNone(mgr.base_currency) 24 | self.assertIsNotNone(mgr.exchange_rates) 25 | 26 | def test_base_currency(self): 27 | """Test call to 'getBaseCurrency'""" 28 | 29 | mgr = CurrencyManager(self.api) 30 | base = mgr.getBaseCurrency() 31 | 32 | self.assertEqual(base, 'USD') 33 | self.assertIsNotNone(mgr.exchange_rates) 34 | 35 | def test_exchange_rates(self): 36 | """Test call to 'getExchangeRates'""" 37 | 38 | mgr = CurrencyManager(self.api) 39 | rates = mgr.getExchangeRates() 40 | 41 | self.assertIsNotNone(rates) 42 | self.assertIsNotNone(mgr.base_currency) 43 | 44 | def test_conversion(self): 45 | """Test currency conversion""" 46 | 47 | mgr = CurrencyManager(self.api) 48 | mgr.updateFromServer() 49 | 50 | # Override data 51 | mgr.base_currency = 'USD' 52 | mgr.exchange_rates = { 53 | 'USD': 1.00, 54 | 'AUD': 1.40, 55 | 'EUR': 0.90, 56 | 'JPY': 128 57 | } 58 | 59 | test_sets = [ 60 | (1, 'AUD', 'USD', 0.7143), 61 | (1, 'EUR', 'USD', 1.1111), 62 | (5, 'JPY', 'AUD', 0.0547), 63 | (5, 'AUD', 'EUR', 3.2143), 64 | (1, 'USD', 'JPY', 128) 65 | ] 66 | 67 | for value, source, target, result in test_sets: 68 | converted = mgr.convertCurrency(value, source, target) 69 | 70 | self.assertEqual(result, round(converted, 4)) 71 | -------------------------------------------------------------------------------- /test/test_internal_price.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 7 | 8 | from test_api import InvenTreeTestCase # noqa: E402 9 | 10 | from inventree.part import InternalPrice, Part # noqa: E402 11 | 12 | 13 | class InternalPriceTest(InvenTreeTestCase): 14 | """ Test that the InternalPrice related objects can be managed via the API """ 15 | 16 | def test_fields(self): 17 | """ 18 | Test field names via OPTIONS request 19 | """ 20 | 21 | field_names = InternalPrice.fieldNames(self.api) 22 | 23 | for field in [ 24 | 'part', 25 | 'quantity', 26 | 'price' 27 | ]: 28 | self.assertIn(field, field_names) 29 | 30 | def test_internal_price_create(self): 31 | """ 32 | Tests the ability to create an internal price 33 | """ 34 | p = Part.create(self.api, { 35 | 'name': 'Test Part', 36 | 'description': 'Test Part', 37 | 'category': 1, 38 | 'revision': 1, 39 | 'active': True, 40 | }) 41 | 42 | self.assertIsNotNone(p) 43 | self.assertIsNotNone(p.pk) 44 | 45 | ip = InternalPrice.create(self.api, { 46 | 'part': p.pk, 47 | 'quantity': 1, 48 | 'price': '1.00' 49 | }) 50 | 51 | self.assertIsNotNone(ip) 52 | -------------------------------------------------------------------------------- /test/test_label.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 7 | 8 | from test_api import InvenTreeTestCase # noqa: E402 9 | 10 | from inventree.label import LabelTemplate # noqa: E402 11 | from inventree.part import Part # noqa: E402 12 | from inventree.plugin import InvenTreePlugin # noqa: E402 13 | 14 | 15 | class LabelTemplateTests(InvenTreeTestCase): 16 | """Unit tests for label templates and printing, using the modern API.""" 17 | 18 | def test_label_template_list(self): 19 | """List available label templates.""" 20 | 21 | n = LabelTemplate.count(self.api) 22 | templates = LabelTemplate.list(self.api) 23 | 24 | self.assertEqual(n, len(templates)) 25 | self.assertGreater(len(templates), 0) 26 | 27 | # Check some expected attributes 28 | for attr in ['name', 'description', 'enabled', 'model_type', 'template']: 29 | for template in templates: 30 | self.assertIn(attr, template) 31 | 32 | for idx, template in enumerate(templates): 33 | enabled = idx > 0 34 | 35 | if template.enabled != enabled: 36 | template.save(data={'enabled': idx > 0}) 37 | 38 | # Filter by 'enabled' status 39 | templates = LabelTemplate.list(self.api, enabled=True) 40 | self.assertGreater(len(templates), 0) 41 | self.assertLess(len(templates), n) 42 | 43 | # Filter by 'disabled' status 44 | templates = LabelTemplate.list(self.api, enabled=False) 45 | self.assertGreater(len(templates), 0) 46 | self.assertLess(len(templates), n) 47 | 48 | def test_label_print(self): 49 | """Print a template!""" 50 | 51 | # Find a part to print 52 | part = Part.list(self.api, limit=1)[0] 53 | 54 | templates = part.getLabelTemplates() 55 | self.assertGreater(len(templates), 0) 56 | 57 | template = templates[0] 58 | 59 | # Find an available plugin 60 | plugins = InvenTreePlugin.list(self.api, active=True, mixin='labels') 61 | self.assertGreater(len(plugins), 0) 62 | 63 | plugin = plugins[0] 64 | 65 | response = part.printLabel(template, plugin=plugin) 66 | 67 | for key in ['created', 'complete', 'output']: 68 | self.assertIn(key, response) 69 | 70 | self.assertEqual(response['complete'], True) 71 | self.assertIsNotNone(response['output']) 72 | -------------------------------------------------------------------------------- /test/test_part.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | 6 | import requests 7 | from requests.exceptions import HTTPError 8 | 9 | try: 10 | import Image 11 | except ImportError: 12 | from PIL import Image 13 | 14 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 15 | 16 | from test_api import InvenTreeTestCase # noqa: E402 17 | 18 | from inventree.base import Attachment # noqa: E402 19 | from inventree.company import SupplierPart # noqa: E402 20 | from inventree.part import InternalPrice # noqa: E402 21 | from inventree.part import (BomItem, Parameter, # noqa: E402 22 | ParameterTemplate, Part, 23 | PartCategory, PartCategoryParameterTemplate, 24 | PartRelated, PartTestTemplate) 25 | from inventree.stock import StockItem # noqa: E402 26 | 27 | 28 | class PartCategoryTest(InvenTreeTestCase): 29 | """Tests for PartCategory models""" 30 | 31 | def test_part_cats(self): 32 | """ 33 | Tests for category filtering 34 | """ 35 | 36 | # All categories 37 | cats = PartCategory.list(self.api) 38 | n = len(cats) 39 | self.assertTrue(len(cats) >= 9) 40 | 41 | # Check that the 'count' method returns the same result 42 | self.assertEqual(n, PartCategory.count(self.api)) 43 | 44 | # Filtered categories must be fewer than *all* categories 45 | cats = PartCategory.list(self.api, parent=1) 46 | 47 | self.assertGreater(len(cats), 0) 48 | self.assertLess(len(cats), n) 49 | 50 | def test_elec(self): 51 | electronics = PartCategory(self.api, 1) 52 | 53 | # This is a top-level category, should not have a parent! 54 | self.assertIsNone(electronics.getParentCategory()) 55 | self.assertEqual(electronics.name, "Electronics") 56 | 57 | children = electronics.getChildCategories() 58 | self.assertGreaterEqual(len(children), 3) 59 | 60 | for child in children: 61 | self.assertEqual(child.parent, 1) 62 | 63 | child = PartCategory(self.api, pk=3) 64 | self.assertEqual(child.name, 'Capacitors') 65 | self.assertEqual(child.getParentCategory().pk, electronics.pk) 66 | 67 | # Grab all child categories 68 | children = PartCategory.list(self.api, parent=child.pk) 69 | 70 | n = len(children) 71 | 72 | for c in children: 73 | self.assertEqual(c.parent, child.pk) 74 | 75 | # Create some new categories under this one 76 | for idx in range(3): 77 | name = f"Subcategory {n+idx}" 78 | 79 | cat = PartCategory.create(self.api, { 80 | "parent": child.pk, 81 | "name": name, 82 | "description": "A new subcategory", 83 | }) 84 | 85 | self.assertEqual(cat.parent, child.pk) 86 | self.assertEqual(cat.name, name) 87 | 88 | # Edit the name of the new location 89 | cat.save({ 90 | "name": f"{name}_suffix", 91 | }) 92 | 93 | # Reload from server, and check 94 | cat.reload() 95 | self.assertEqual(cat.name, f"{name}_suffix") 96 | 97 | # Number of children should have increased! 98 | self.assertEqual(len(child.getChildCategories()), n + 3) 99 | 100 | def test_caps(self): 101 | 102 | cat = PartCategory(self.api, 6) 103 | self.assertEqual(cat.name, "Transceivers") 104 | parts = cat.getParts() 105 | 106 | n_parts = len(parts) 107 | 108 | for p in parts: 109 | self.assertEqual(p.category, cat.pk) 110 | 111 | # Create some new parts 112 | for i in range(10): 113 | 114 | name = f"Part_{cat.pk}_{n_parts + i}" 115 | 116 | prt = Part.create(self.api, { 117 | "category": cat.pk, 118 | "name": name, 119 | "description": "A new part in this category", 120 | }) 121 | 122 | self.assertIsNotNone(prt) 123 | 124 | self.assertEqual(prt.name, name) 125 | 126 | parts = cat.getParts() 127 | 128 | self.assertEqual(len(parts), n_parts + 10) 129 | 130 | def test_part_category_parameter_templates(self): 131 | """Unit tests for the PartCategoryParameterTemplate model""" 132 | 133 | electronics = PartCategory(self.api, pk=3) 134 | 135 | # Ensure there are some parameter templates associated with this category 136 | templates = electronics.getCategoryParameterTemplates(fetch_parent=False) 137 | 138 | if len(templates) == 0: 139 | for name in ['wodth', 'lungth', 'herght']: 140 | template = ParameterTemplate.create(self.api, data={ 141 | 'name': name, 142 | 'units': 'mm', 143 | }) 144 | 145 | pcpt = PartCategoryParameterTemplate.create( 146 | self.api, 147 | data={ 148 | 'category': electronics.pk, 149 | 'parameter_template': template.pk, 150 | 'default_value': 123, 151 | } 152 | ) 153 | 154 | # Check that model lookup functions work 155 | self.assertEqual(pcpt.getCategory().pk, electronics.pk) 156 | self.assertEqual(pcpt.getTemplate().pk, template.pk) 157 | 158 | # Reload 159 | templates = electronics.getCategoryParameterTemplates(fetch_parent=False) 160 | 161 | self.assertTrue(len(templates) >= 3) 162 | 163 | # Check child categories 164 | children = electronics.getChildCategories() 165 | 166 | self.assertTrue(len(children) > 0) 167 | 168 | for child in children: 169 | child_templates = child.getCategoryParameterTemplates(fetch_parent=True) 170 | self.assertTrue(len(child_templates) >= 3) 171 | 172 | 173 | class PartTest(InvenTreeTestCase): 174 | """Tests for Part models""" 175 | 176 | def test_part_get_functions(self): 177 | """Test various functions of Part class, mostly starting with get... 178 | These are wrappers for other functions, so the testing of details of the function should 179 | be done elsewhere.""" 180 | 181 | # Get list of parts 182 | parts = Part.list(self.api) 183 | 184 | functions = { 185 | 'getSupplierParts': SupplierPart, 186 | 'getBomItems': BomItem, 187 | 'isUsedIn': BomItem, 188 | 'getStockItems': StockItem, 189 | 'getParameters': Parameter, 190 | 'getRelated': PartRelated, 191 | 'getInternalPriceList': InternalPrice, 192 | } 193 | 194 | if self.api.api_version >= Attachment.MIN_API_VERSION: 195 | functions['getAttachments'] = Attachment 196 | 197 | # For each part in list, test some functions 198 | for p in parts: 199 | 200 | for fnc, res in functions.items(): 201 | A = getattr(p, fnc)() 202 | # Make sure a list is returned 203 | self.assertIsInstance(A, list) 204 | for a in A: 205 | # Make sure any result is of the right class 206 | self.assertIsInstance(a, res) 207 | 208 | def test_access_erors(self): 209 | """ 210 | Test that errors are flagged when we try to access an invalid part 211 | """ 212 | 213 | with self.assertRaises(TypeError): 214 | Part(self.api, 'hello') 215 | 216 | with self.assertRaises(ValueError): 217 | Part(self.api, -1) 218 | 219 | # Try to access a Part which does not exist 220 | with self.assertRaises(requests.exceptions.HTTPError): 221 | Part(self.api, 9999999999999) 222 | 223 | def test_fields(self): 224 | """ 225 | Test field names via OPTIONS request 226 | """ 227 | 228 | field_names = Part.fieldNames(self.api) 229 | 230 | self.assertIn('active', field_names) 231 | self.assertIn('revision', field_names) 232 | self.assertIn('full_name', field_names) 233 | self.assertIn('IPN', field_names) 234 | 235 | def test_options(self): 236 | """Extends tests for OPTIONS model metadata""" 237 | 238 | # Check for field which does not exist 239 | with self.assertLogs(): 240 | Part.fieldInfo('abcde', self.api) 241 | 242 | active = Part.fieldInfo('active', self.api) 243 | 244 | self.assertEqual(active['type'], 'boolean') 245 | self.assertEqual(active['label'], 'Active') 246 | self.assertEqual(active['default'], True) 247 | 248 | for field_name in [ 249 | 'name', 250 | 'description', 251 | 'component', 252 | 'assembly', 253 | ]: 254 | field = Part.fieldInfo(field_name, self.api) 255 | 256 | # Check required field attributes 257 | for attr in ['type', 'required', 'read_only', 'label', 'help_text']: 258 | self.assertIn(attr, field) 259 | 260 | def test_pagination(self): 261 | """ Test that we can paginate the queryset by specifying a 'limit' parameter""" 262 | 263 | parts = Part.list(self.api, limit=5) 264 | self.assertEqual(len(parts), 5) 265 | 266 | for p in parts: 267 | self.assertTrue(type(p) is Part) 268 | 269 | def test_part_list(self): 270 | """ 271 | Check that we can list Part objects, 272 | and apply certain filters 273 | """ 274 | 275 | parts = Part.list(self.api) 276 | self.assertTrue(len(parts) >= 19) 277 | 278 | parts = Part.list(self.api, category=5) 279 | 280 | n = len(parts) 281 | 282 | for i in range(5): 283 | prt = Part.create(self.api, { 284 | "category": 5, 285 | "name": f"Special Part {n+i}", 286 | "description": "A new part in this category!", 287 | }) 288 | 289 | self.assertEqual(prt.category, 5) 290 | cat = prt.getCategory() 291 | self.assertEqual(cat.pk, 5) 292 | 293 | parts = cat.getParts() 294 | 295 | self.assertGreaterEqual(len(parts), i + 1) 296 | 297 | def test_part_edit(self): 298 | """ 299 | Test that we can edit a part 300 | """ 301 | 302 | # Select a part 303 | p = Part.list(self.api)[-1] 304 | 305 | name = p.name 306 | 307 | # Adjust the name 308 | if len(name) < 40: 309 | name += '_append' 310 | else: 311 | name = name[:-10] 312 | 313 | p.save( 314 | data={ 315 | 'name': name, 316 | 'description': 'A new description' 317 | }, 318 | ) 319 | p.reload() 320 | 321 | self.assertEqual(p.name, name) 322 | self.assertEqual(p.description, 'A new description') 323 | 324 | def test_default_values(self): 325 | """ 326 | Test that the DRF framework will correctly insert the default values 327 | """ 328 | 329 | n = len(Part.list(self.api)) 330 | 331 | # Create a part without specifying 'active' and 'virtual' fields 332 | p = Part.create( 333 | self.api, 334 | { 335 | 'name': f"Part_{n}_default_test", 336 | 'category': 1, 337 | 'description': "Some part thingy", 338 | } 339 | ) 340 | 341 | self.assertEqual(p.active, True) 342 | self.assertEqual(p.virtual, False) 343 | 344 | # Set both to false 345 | p = Part.create( 346 | self.api, 347 | { 348 | 'name': f"Part_{n}_default_test_2", 349 | 'category': 1, 350 | 'description': 'Setting fields to false', 351 | 'active': False, 352 | 'virtual': False, 353 | } 354 | ) 355 | 356 | self.assertFalse(p.active) 357 | self.assertFalse(p.virtual) 358 | 359 | # Set both to true 360 | p = Part.create( 361 | self.api, 362 | { 363 | 'name': f"Part_{n}_default_test_3", 364 | 'category': 1, 365 | 'description': 'Setting fields to true', 366 | 'active': True, 367 | 'virtual': True, 368 | } 369 | ) 370 | 371 | self.assertTrue(p.active) 372 | self.assertTrue(p.virtual) 373 | 374 | def test_part_delete(self): 375 | """ 376 | Test we can create and delete a Part instance via the API 377 | """ 378 | 379 | n = len(Part.list(self.api)) 380 | 381 | # Create a new part 382 | # We do not specify 'active' value so it will default to True 383 | p = Part.create( 384 | self.api, 385 | { 386 | 'name': 'Delete Me', 387 | 'description': 'Not long for this world!', 388 | 'category': 1, 389 | } 390 | ) 391 | 392 | self.assertIsNotNone(p) 393 | self.assertIsNotNone(p.pk) 394 | 395 | self.assertEqual(len(Part.list(self.api)), n + 1) 396 | 397 | # Cannot delete - part is 'active'! 398 | with self.assertRaises(requests.exceptions.HTTPError) as ar: 399 | response = p.delete() 400 | 401 | self.assertIn("Cannot delete this part as it is still active", str(ar.exception)) 402 | 403 | p.save(data={'active': False}) 404 | response = p.delete() 405 | self.assertEqual(response.status_code, 204) 406 | 407 | # And check that the part has indeed been deleted 408 | self.assertEqual(len(Part.list(self.api)), n) 409 | 410 | def test_image_upload(self): 411 | """ 412 | Test image upload functionality for Part model 413 | """ 414 | 415 | # Grab the first part 416 | p = Part.list(self.api)[0] 417 | 418 | # Ensure the part does *not* have an image associated with it 419 | p.save(data={'image': None}) 420 | 421 | # Create a dummy file (not an image) 422 | with open('dummy_image.jpg', 'w') as dummy_file: 423 | dummy_file.write("hello world") 424 | 425 | # Attempt to upload an image 426 | with self.assertRaises(requests.exceptions.HTTPError): 427 | response = p.uploadImage("dummy_image.jpg") 428 | 429 | # Now, let's actually upload a real image 430 | img = Image.new('RGB', (128, 128), color='red') 431 | img.save('dummy_image.png') 432 | 433 | response = p.uploadImage("dummy_image.png") 434 | 435 | self.assertIsNotNone(response) 436 | self.assertIsNotNone(p['image']) 437 | self.assertIn('dummy_image', p['image']) 438 | 439 | # Re-download the image file 440 | fout = 'test/output.png' 441 | 442 | if os.path.exists(fout): 443 | # Delete the file if it already exists 444 | os.remove(fout) 445 | 446 | response = p.downloadImage(fout) 447 | self.assertTrue(response) 448 | 449 | self.assertTrue(os.path.exists(fout)) 450 | 451 | # Attempt to re-download 452 | with self.assertRaises(FileExistsError): 453 | p.downloadImage(fout) 454 | 455 | # Download, with overwrite enabled 456 | p.downloadImage(fout, overwrite=True) 457 | 458 | def test_part_attachment(self): 459 | """ 460 | Check that we can upload attachment files against the part 461 | """ 462 | 463 | if self.api.api_version < Attachment.MIN_API_VERSION: 464 | return 465 | 466 | prt = Part(self.api, pk=1) 467 | attachments = prt.getAttachments() 468 | 469 | for a in attachments: 470 | self.assertEqual(a.model_type, 'part') 471 | self.assertEqual(a.model_id, prt.pk) 472 | 473 | n = len(attachments) 474 | 475 | # Test that attempting to upload an invalid file fails 476 | with self.assertRaises(FileNotFoundError): 477 | prt.uploadAttachment('test-file.txt') 478 | 479 | # Check that no new files have been uploaded 480 | self.assertEqual(len(prt.getAttachments()), n) 481 | 482 | # Test that we can upload a file by filename, directly from the Part instance 483 | filename = os.path.join(os.path.dirname(__file__), 'docker-compose.yml') 484 | 485 | response = prt.uploadAttachment( 486 | filename, 487 | comment='Uploading a file' 488 | ) 489 | 490 | self.assertIsNotNone(response) 491 | 492 | pk = response['pk'] 493 | 494 | # Check that a new attachment has been created! 495 | attachment = Attachment(self.api, pk=pk) 496 | self.assertTrue(attachment.is_valid()) 497 | 498 | # Download the attachment to a local file! 499 | dst = os.path.join(os.path.dirname(__file__), 'test.tmp') 500 | attachment.download(dst, overwrite=True) 501 | 502 | self.assertTrue(os.path.exists(dst)) 503 | self.assertTrue(os.path.isfile(dst)) 504 | 505 | with self.assertRaises(FileExistsError): 506 | # Attempt to download the file again, but without overwrite option 507 | attachment.download(dst) 508 | 509 | def test_part_link_attachment(self): 510 | """ 511 | Check that we can add an external link attachment to the part 512 | """ 513 | 514 | if self.api.api_version < Attachment.MIN_API_VERSION: 515 | return 516 | 517 | test_link = "https://inventree.org/" 518 | test_comment = "inventree.org" 519 | 520 | # Add valid external link attachment 521 | part = Part(self.api, pk=1) 522 | response = part.addLinkAttachment(test_link, comment=test_comment) 523 | self.assertIsNotNone(response) 524 | 525 | # Check that the attachment has been created 526 | attachment = Attachment(self.api, pk=response["pk"]) 527 | self.assertTrue(attachment.is_valid()) 528 | self.assertEqual(attachment.link, test_link) 529 | self.assertEqual(attachment.comment, test_comment) 530 | 531 | def test_set_price(self): 532 | """ 533 | Tests that an internal price can be set for a part 534 | """ 535 | 536 | test_price = 100.0 537 | test_quantity = 1 538 | 539 | # Grab the first part 540 | p = Part.list(self.api)[0] 541 | 542 | # Grab all internal prices for the part 543 | ip = InternalPrice.list(self.api, part=p.pk) 544 | 545 | # Delete any existing prices 546 | for price in ip: 547 | self.assertEqual(type(price), InternalPrice) 548 | price.delete() 549 | 550 | # Ensure that no part has an internal price 551 | ip = InternalPrice.list(self.api, part=p.pk) 552 | self.assertEqual(len(ip), 0) 553 | 554 | # Set the internal price 555 | p.setInternalPrice(test_quantity, test_price) 556 | 557 | # Ensure that the part has an internal price 558 | ip = InternalPrice.list(self.api, part=p.pk) 559 | self.assertEqual(len(ip), 1) 560 | 561 | # Grab the internal price 562 | ip = ip[0] 563 | 564 | self.assertEqual(ip.quantity, test_quantity) 565 | self.assertEqual(ip.part, p.pk) 566 | ip_price_clean = float(ip.price) 567 | self.assertEqual(ip_price_clean, test_price) 568 | 569 | def test_parameters(self): 570 | """ 571 | Test setting and getting Part parameter templates, as well as parameter values 572 | """ 573 | 574 | # Count number of existing Parameter Templates 575 | existingTemplates = len(ParameterTemplate.list(self.api)) 576 | 577 | # Create new parameter template - this will fail, no name given 578 | with self.assertRaises(HTTPError): 579 | parametertemplate = ParameterTemplate.create(self.api, data={'units': "kg A"}) 580 | 581 | # Now create a proper parameter template 582 | parametertemplate = ParameterTemplate.create(self.api, data={'name': f'Test parameter no {existingTemplates}', 'units': "kg A"}) 583 | 584 | # result should not be None 585 | self.assertIsNotNone(parametertemplate) 586 | 587 | # Count should be one higher now 588 | self.assertEqual(len(ParameterTemplate.list(self.api)), existingTemplates + 1) 589 | 590 | # Grab the first part 591 | p = Part.list(self.api)[0] 592 | 593 | # Count number of parameters 594 | existingParameters = len(p.getParameters()) 595 | 596 | # Define parameter value for this part - without all required values 597 | with self.assertRaises(HTTPError): 598 | Parameter.create(self.api, data={'part': p.pk, 'template': parametertemplate.pk}) 599 | 600 | # Define parameter value for this part - without all required values 601 | with self.assertRaises(HTTPError): 602 | Parameter.create(self.api, data={'part': p.pk, 'data': 10}) 603 | 604 | # Define w. required values - integer 605 | param = Parameter.create(self.api, data={'part': p.pk, 'template': parametertemplate.pk, 'data': 10}) 606 | 607 | # Unit should be equal 608 | self.assertEqual(param.getunits(), 'kg A') 609 | 610 | # result should not be None 611 | self.assertIsNotNone(param) 612 | 613 | # Same parameter for same part - should fail 614 | # Define w. required values - string 615 | with self.assertRaises(HTTPError): 616 | Parameter.create(self.api, data={'part': p.pk, 'template': parametertemplate.pk, 'data': 'String value'}) 617 | 618 | # Number of parameters should be one higher than before 619 | self.assertEqual(len(p.getParameters()), existingParameters + 1) 620 | 621 | # Delete the parameter 622 | param.delete() 623 | 624 | # Check count 625 | self.assertEqual(len(p.getParameters()), existingParameters) 626 | 627 | # Delete the parameter template 628 | parametertemplate.delete() 629 | 630 | # Check count 631 | self.assertEqual(len(ParameterTemplate.list(self.api)), existingTemplates) 632 | 633 | def test_metadata(self): 634 | """Test Part instance metadata""" 635 | 636 | # Grab the first available part 637 | part = Part.list(self.api, limit=1)[0] 638 | 639 | part.setMetadata( 640 | { 641 | "foo": "bar", 642 | }, 643 | overwrite=True, 644 | ) 645 | 646 | metadata = part.getMetadata() 647 | 648 | # Check that the metadata has been overwritten 649 | self.assertEqual(len(metadata.keys()), 1) 650 | 651 | self.assertEqual(metadata['foo'], 'bar') 652 | 653 | # Now 'patch' in some metadata 654 | part.setMetadata( 655 | { 656 | 'hello': 'world', 657 | }, 658 | ) 659 | 660 | part.setMetadata( 661 | { 662 | 'foo': 'rab', 663 | } 664 | ) 665 | 666 | metadata = part.getMetadata() 667 | 668 | self.assertEqual(len(metadata.keys()), 2) 669 | self.assertEqual(metadata['foo'], 'rab') 670 | self.assertEqual(metadata['hello'], 'world') 671 | 672 | def test_part_related(self): 673 | """Test add related function""" 674 | 675 | parts = Part.list(self.api) 676 | 677 | # First, ensure *all* related parts are deleted 678 | for relation in PartRelated.list(self.api): 679 | relation.delete() 680 | 681 | # Take two parts, make them related 682 | # Try with pk values 683 | ret = PartRelated.add_related(self.api, parts[0].pk, parts[1].pk) 684 | self.assertTrue(ret) 685 | 686 | # Take two parts, make them related 687 | # Try with Part object 688 | ret = PartRelated.add_related(self.api, parts[2], parts[3]) 689 | self.assertTrue(ret) 690 | 691 | # Take the same part twice, should fail 692 | with self.assertRaises(HTTPError): 693 | ret = PartRelated.add_related(self.api, parts[3], parts[3]) 694 | 695 | def test_get_requirements(self): 696 | """Test getRequirements function for parts""" 697 | 698 | # Get first part 699 | prt = Part.list(self.api, limit=1)[0] 700 | 701 | # Get requirements list 702 | req = prt.getRequirements() 703 | 704 | # Check for expected content 705 | self.assertIsInstance(req, dict) 706 | self.assertIn('available_stock', req) 707 | self.assertIn('on_order', req) 708 | self.assertIn('required_build_order_quantity', req) 709 | self.assertIn('allocated_build_order_quantity', req) 710 | self.assertIn('required_sales_order_quantity', req) 711 | self.assertIn('allocated_sales_order_quantity', req) 712 | self.assertIn('allocated', req) 713 | self.assertIn('required', req) 714 | 715 | 716 | class PartBarcodeTest(InvenTreeTestCase): 717 | """Tests for Part barcode functionality""" 718 | 719 | def test_barcode_assign(self): 720 | """Tests for assigning barcodes to Part instances""" 721 | 722 | barcode = 'ABCD-1234-XYZ' 723 | 724 | # Grab a part from the database 725 | part_1 = Part(self.api, pk=1) 726 | 727 | # First ensure that there is *no* barcode assigned to this item 728 | part_1.unassignBarcode() 729 | 730 | # Assign a barcode to this part (should auto-reload) 731 | response = part_1.assignBarcode(barcode) 732 | 733 | self.assertEqual(response['success'], 'Assigned barcode to part instance') 734 | self.assertEqual(response['barcode_data'], barcode) 735 | 736 | # Attempt to assign the same barcode to a different part (should error) 737 | part_2 = Part(self.api, pk=2) 738 | 739 | # Ensure this part does not have an associated barcode 740 | part_2.unassignBarcode() 741 | 742 | with self.assertRaises(HTTPError): 743 | response = part_2.assignBarcode(barcode) 744 | 745 | # Scan the barcode (should point back to part_1) 746 | response = self.api.scanBarcode(barcode) 747 | 748 | self.assertEqual(response['barcode_data'], barcode) 749 | self.assertEqual(response['part']['pk'], 1) 750 | 751 | # Unassign from part_1 752 | part_1.unassignBarcode() 753 | 754 | # Now assign to part_2 755 | response = part_2.assignBarcode(barcode) 756 | self.assertEqual(response['barcode_data'], barcode) 757 | 758 | # Scan again 759 | response = self.api.scanBarcode(barcode) 760 | self.assertEqual(response['part']['pk'], 2) 761 | 762 | # Unassign from part_2 763 | part_2.unassignBarcode() 764 | 765 | # Scanning this time should yield no results 766 | with self.assertRaises(HTTPError): 767 | response = self.api.scanBarcode(barcode) 768 | 769 | 770 | class PartTestTemplateTest(InvenTreeTestCase): 771 | """Tests for PartTestTemplate functionality""" 772 | 773 | def test_generateKey(self): 774 | """Tests for generating a key for a PartTestTemplate""" 775 | 776 | self.assertEqual(PartTestTemplate.generateTestKey('bob'), 'bob') 777 | self.assertEqual(PartTestTemplate.generateTestKey('bob%35'), 'bob35') 778 | self.assertEqual(PartTestTemplate.generateTestKey('bo b%35'), 'bob35') 779 | self.assertEqual(PartTestTemplate.generateTestKey('BO B%35'), 'bob35') 780 | self.assertEqual(PartTestTemplate.generateTestKey(' % '), '') 781 | self.assertEqual(PartTestTemplate.generateTestKey(''), '') 782 | -------------------------------------------------------------------------------- /test/test_plugin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 7 | 8 | from test_api import InvenTreeTestCase # noqa: E402 9 | 10 | from inventree.plugin import InvenTreePlugin # noqa: E402 11 | 12 | 13 | class PluginTest(InvenTreeTestCase): 14 | """Unit tests for plugin functionality.""" 15 | 16 | def test_plugin_lookup(self): 17 | """Test plugin lookup by key.""" 18 | 19 | if self.api.api_version < InvenTreePlugin.MIN_API_VERSION: 20 | return 21 | 22 | plugins = InvenTreePlugin.list(self.api) 23 | 24 | self.assertGreater(len(plugins), 0) 25 | 26 | p1 = plugins[0] 27 | 28 | # Access the plugin via primary key value 29 | p2 = InvenTreePlugin(self.api, p1.key) 30 | 31 | self.assertEqual(p1.key, p2.key) 32 | self.assertEqual(p1.name, p2.name) 33 | 34 | self.assertEqual(p1._data['pk'], p2._data['pk']) 35 | 36 | def test_plugin_list(self): 37 | """Test plugin list API.""" 38 | 39 | if self.api.api_version < InvenTreePlugin.MIN_API_VERSION: 40 | return 41 | 42 | plugins = InvenTreePlugin.list(self.api) 43 | 44 | expected_attributes = [ 45 | 'pk', 46 | 'key', 47 | 'name', 48 | 'package_name', 49 | 'active', 50 | 'meta', 51 | 'mixins', 52 | 'is_builtin', 53 | 'is_sample', 54 | 'is_installed' 55 | ] 56 | 57 | for plugin in plugins: 58 | for key in expected_attributes: 59 | self.assertIn(key, plugin) 60 | 61 | def test_filter_by_active(self): 62 | """Filter by plugin active status.""" 63 | 64 | if self.api.api_version < InvenTreePlugin.MIN_API_VERSION: 65 | return 66 | 67 | plugins = InvenTreePlugin.list(self.api, active=True) 68 | self.assertGreater(len(plugins), 0) 69 | 70 | plugin = plugins[0] 71 | 72 | for plugin in plugins: 73 | self.assertTrue(plugin.active) 74 | 75 | def test_filter_by_builtin(self): 76 | """Filter by plugin builtin status.""" 77 | 78 | if self.api.api_version < InvenTreePlugin.MIN_API_VERSION: 79 | return 80 | 81 | plugins = InvenTreePlugin.list(self.api, builtin=True) 82 | self.assertGreater(len(plugins), 0) 83 | 84 | for plugin in plugins: 85 | self.assertTrue(plugin.is_builtin) 86 | 87 | def test_filter_by_mixin(self): 88 | """Test that we can filter by 'mixin' attribute.""" 89 | 90 | if self.api.api_version < InvenTreePlugin.MIN_API_VERSION: 91 | return 92 | 93 | n = InvenTreePlugin.count(self.api) 94 | 95 | plugins = InvenTreePlugin.list(self.api, mixin='labels') 96 | 97 | self.assertLess(len(plugins), n) 98 | self.assertGreater(len(plugins), 0) 99 | 100 | for plugin in plugins: 101 | self.assertIn('labels', plugin.mixins) 102 | -------------------------------------------------------------------------------- /test/test_project_codes.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the ProjectCode model""" 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 7 | 8 | from test_api import InvenTreeTestCase # noqa: E402 9 | 10 | from inventree.project_code import ProjectCode # noqa: E402 11 | 12 | 13 | class ProjectCodeTest(InvenTreeTestCase): 14 | """Tests for the ProjectCode model.""" 15 | 16 | def test_project_code_create(self): 17 | """Test we can create a new project code.""" 18 | 19 | n = ProjectCode.count(self.api) 20 | 21 | ProjectCode.create(self.api, { 22 | 'code': f'TEST {n + 1}', 23 | 'description': 'Test project code', 24 | }) 25 | 26 | self.assertEqual(ProjectCode.count(self.api), n + 1) 27 | 28 | # Try to create a duplicate code 29 | with self.assertRaises(Exception): 30 | ProjectCode.create(self.api, { 31 | 'code': f'TEST {n + 1}', 32 | 'description': 'Test project code', 33 | }) 34 | 35 | n = ProjectCode.count(self.api) 36 | 37 | # Create 5 more codes 38 | for idx in range(5): 39 | ProjectCode.create(self.api, { 40 | 'code': f'CODE-{idx + n}', 41 | 'description': f'Description {idx + n}', 42 | }) 43 | 44 | # List all codes 45 | codes = ProjectCode.list(self.api) 46 | 47 | self.assertEqual(len(codes), n + 5) 48 | -------------------------------------------------------------------------------- /test/test_report.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 7 | 8 | from test_api import InvenTreeTestCase # noqa: E402 9 | 10 | from inventree.build import Build # noqa: E402 11 | from inventree.report import ReportTemplate # noqa: E402 12 | 13 | 14 | class ReportClassesTest(InvenTreeTestCase): 15 | """Tests for Report functions models""" 16 | 17 | def test_list_templates(self): 18 | """List the available report templates.""" 19 | 20 | templates = ReportTemplate.list(self.api) 21 | 22 | self.assertGreater(len(templates), 0) 23 | 24 | for template in templates: 25 | for key in ['name', 'description', 'enabled', 'model_type', 'template']: 26 | self.assertIn(key, template) 27 | 28 | # disable a template 29 | templates[0].save(data={'enabled': False}) 30 | 31 | templates = ReportTemplate.list(self.api, enabled=False) 32 | self.assertGreater(len(templates), 0) 33 | 34 | # enable a template 35 | templates[0].save(data={'enabled': True}) 36 | 37 | templates = ReportTemplate.list(self.api, enabled=True) 38 | self.assertGreater(len(templates), 0) 39 | 40 | def test_print_report(self): 41 | """Test report printing.""" 42 | 43 | # Find a build to print 44 | build = Build.list(self.api, limit=1)[0] 45 | 46 | templates = build.getReportTemplates() 47 | self.assertGreater(len(templates), 0) 48 | 49 | template = templates[0] 50 | 51 | # Print the report 52 | response = build.printReport(template) 53 | 54 | for key in ['pk', 'output']: 55 | self.assertIn(key, response) 56 | 57 | self.assertIsNotNone(response['output']) 58 | -------------------------------------------------------------------------------- /test/test_stock.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | 6 | import requests 7 | 8 | sys.path.append(os.path.abspath(os.path.dirname(__file__))) 9 | 10 | from test_api import InvenTreeTestCase # noqa: E402 11 | 12 | from inventree import company # noqa: E402 13 | from inventree import part # noqa: E402 14 | from inventree.stock import StockItem, StockLocation # noqa: E402 15 | 16 | 17 | class StockLocationTest(InvenTreeTestCase): 18 | """ 19 | Tests for the StockLocation model 20 | 21 | Fixture data can be found in the InvenTree source: 22 | 23 | - InvenTree/stock/fixtures/location.yaml 24 | 25 | """ 26 | 27 | def test_location_list(self): 28 | """ 29 | Test the LIST API endpoint for the StockLocation model 30 | """ 31 | 32 | locs = StockLocation.list(self.api) 33 | self.assertGreaterEqual(len(locs), 4) 34 | 35 | for loc in locs: 36 | self.assertEqual(type(loc), StockLocation) 37 | 38 | def test_location_create(self): 39 | """ 40 | Check that we can create a new stock location via the APi 41 | """ 42 | 43 | n = len(StockLocation.list(self.api)) 44 | 45 | parent = StockLocation(self.api, pk=7) 46 | 47 | n_childs = len(parent.getChildLocations()) 48 | 49 | # Create a sublocation with a unique name 50 | location = StockLocation.create( 51 | self.api, 52 | { 53 | "name": f"My special location {n_childs}", 54 | "description": "A location created with the API!", 55 | "parent": 7 56 | } 57 | ) 58 | 59 | self.assertIsNotNone(location) 60 | 61 | # Now, request back via the API using a secondary object 62 | loc = StockLocation(self.api, pk=location.pk) 63 | 64 | self.assertEqual(loc.name, f"My special location {n_childs}") 65 | self.assertEqual(loc.parent, 7) 66 | 67 | # Change the name of the location 68 | loc.save({ 69 | "name": f"A whole new name {n_childs}", 70 | }) 71 | 72 | # Reload the original object 73 | location.reload() 74 | 75 | self.assertEqual(location.name, f"A whole new name {n_childs}") 76 | 77 | # Check that the number of locations has been updated 78 | locs = StockLocation.list(self.api) 79 | self.assertEqual(len(locs), n + 1) 80 | 81 | def test_location_stock(self): 82 | """Query stock by location""" 83 | location = StockLocation(self.api, pk=4) 84 | 85 | self.assertEqual(location.pk, 4) 86 | self.assertEqual(location.description, "Place of work") 87 | 88 | items = location.getStockItems() 89 | 90 | self.assertGreaterEqual(len(items), 15) 91 | 92 | # Check specific part stock in location 1 (initially empty) 93 | items = location.getStockItems(part=1) 94 | 95 | n = len(items) 96 | 97 | for i in range(5): 98 | StockItem.create( 99 | self.api, 100 | { 101 | "part": 1, 102 | "quantity": (i + 1) * 50, 103 | "location": location.pk, 104 | } 105 | ) 106 | 107 | items = location.getStockItems(part=1) 108 | self.assertEqual(len(items), n + i + 1) 109 | 110 | items = location.getStockItems(part=5) 111 | self.assertGreaterEqual(len(items), 1) 112 | 113 | for item in items: 114 | self.assertEqual(item.location, location.pk) 115 | self.assertEqual(item.part, 5) 116 | 117 | def test_location_parent(self): 118 | """ 119 | Return the Parent location 120 | """ 121 | 122 | # This location does not have a parent 123 | location = StockLocation(self.api, pk=4) 124 | self.assertIsNone(location.parent) 125 | parent = location.getParentLocation() 126 | 127 | self.assertIsNone(parent) 128 | 129 | # Now, get a location which *does* have a parent 130 | location = StockLocation(self.api, pk=7) 131 | self.assertIsNotNone(location.parent) 132 | self.assertEqual(location.parent, 4) 133 | 134 | parent = location.getParentLocation() 135 | 136 | self.assertEqual(type(parent), StockLocation) 137 | self.assertEqual(parent.pk, 4) 138 | self.assertIsNone(parent.parent) 139 | self.assertIsNone(parent.getParentLocation()) 140 | 141 | children = parent.getChildLocations() 142 | self.assertGreaterEqual(len(children), 2) 143 | 144 | for child in children: 145 | self.assertEqual(type(child), StockLocation) 146 | self.assertEqual(child.parent, parent.pk) 147 | 148 | 149 | class StockTest(InvenTreeTestCase): 150 | """ 151 | Test alternative ways of getting StockItem objects. 152 | 153 | Fixture data can be found in the InvenTree source: 154 | 155 | - InvenTree/stock/fixtures/stock.yaml 156 | """ 157 | 158 | def test_stock(self): 159 | 160 | items = StockItem.list(self.api, part=1) 161 | 162 | n = len(items) 163 | 164 | self.assertGreaterEqual(n, 2) 165 | 166 | for item in items: 167 | self.assertEqual(item.part, 1) 168 | 169 | # Request via the Part instance (results should be the same!) 170 | items = part.Part(self.api, 1).getStockItems() 171 | self.assertEqual(len(items), n) 172 | 173 | def test_get_stock_item(self): 174 | """ 175 | StockItem API tests. 176 | 177 | Refer to fixture data in InvenTree/stock/fixtures/stock.yaml 178 | """ 179 | 180 | # Grab the first available stock item 181 | item = StockItem.list(self.api, in_stock=True, limit=1)[0] 182 | 183 | # Get the Part reference 184 | prt = item.getPart() 185 | 186 | self.assertEqual(type(prt), part.Part) 187 | 188 | # Move the item to a known location 189 | item.transferStock(3) 190 | item.reload() 191 | 192 | location = item.getLocation() 193 | 194 | self.assertEqual(type(location), StockLocation) 195 | self.assertEqual(location.pk, 3) 196 | self.assertEqual(location.name, "Dining Room") 197 | 198 | def test_bulk_delete(self): 199 | """Test bulk deletion of stock items""" 200 | 201 | # Add some items to location 3 202 | for i in range(10): 203 | StockItem.create(self.api, { 204 | 'location': 3, 205 | 'part': 1, 206 | 'quantity': i + 50, 207 | }) 208 | 209 | self.assertTrue(len(StockItem.list(self.api, location=3)) >= 10) 210 | 211 | # Delete *all* items from location 3 212 | StockItem.bulkDelete(self.api, filters={ 213 | 'location': 3 214 | }) 215 | 216 | loc = StockLocation(self.api, pk=3) 217 | items = loc.getStockItems() 218 | self.assertEqual(len(items), 0) 219 | 220 | def test_barcode_support(self): 221 | """Test barcode support for the StockItem model""" 222 | 223 | items = StockItem.list(self.api, limit=10) 224 | 225 | for item in items: 226 | # Delete any existing barcode 227 | item.unassignBarcode() 228 | 229 | # Perform lookup based on 'internal' barcode 230 | response = self.api.scanBarcode( 231 | { 232 | "stockitem": item.pk, 233 | } 234 | ) 235 | 236 | self.assertEqual(response['stockitem']['pk'], item.pk) 237 | self.assertEqual(response['plugin'], 'InvenTreeBarcode') 238 | 239 | # Assign a custom barcode to this StockItem 240 | barcode = f"custom-stock-item-{item.pk}" 241 | item.assignBarcode(barcode) 242 | 243 | response = self.api.scanBarcode(barcode) 244 | 245 | self.assertEqual(response['stockitem']['pk'], item.pk) 246 | self.assertEqual(response['plugin'], 'InvenTreeBarcode') 247 | self.assertEqual(response['barcode_data'], barcode) 248 | 249 | item.unassignBarcode() 250 | 251 | 252 | class StockAdjustTest(InvenTreeTestCase): 253 | """Unit tests for stock 'adjustment' actions""" 254 | 255 | def test_count(self): 256 | """Test the 'count' action""" 257 | 258 | # Find the first available stock item 259 | item = StockItem.list(self.api, in_stock=True, limit=1)[0] 260 | 261 | # Count number of tracking entries 262 | n_tracking = len(item.getTrackingEntries()) 263 | 264 | q = item.quantity 265 | 266 | item.countStock(q + 100) 267 | item.reload() 268 | 269 | self.assertEqual(item.quantity, q + 100) 270 | 271 | item.countStock(q, notes='Why hello there') 272 | item.reload() 273 | self.assertEqual(item.quantity, q) 274 | 275 | # 2 tracking entries should have been added 276 | self.assertEqual( 277 | len(item.getTrackingEntries()), 278 | n_tracking + 2 279 | ) 280 | 281 | # The most recent tracking entry should have a note 282 | t = item.getTrackingEntries()[0] 283 | self.assertEqual(t.label, 'Stock counted') 284 | 285 | # Check error conditions 286 | with self.assertRaises(requests.exceptions.HTTPError): 287 | item.countStock('not a number') 288 | 289 | with self.assertRaises(requests.exceptions.HTTPError): 290 | item.countStock(-1) 291 | 292 | def test_add_remove(self): 293 | """Test the 'add' and 'remove' actions""" 294 | 295 | # Find the first available stock item 296 | item = StockItem.list(self.api, in_stock=True, limit=1)[0] 297 | 298 | n_tracking = len(item.getTrackingEntries()) 299 | 300 | q = item.quantity 301 | 302 | # Add some items 303 | item.addStock(10) 304 | item.reload() 305 | self.assertEqual(item.quantity, q + 10) 306 | 307 | # Remove the items again 308 | item.removeStock(10) 309 | item.reload() 310 | self.assertEqual(item.quantity, q) 311 | 312 | # 2 additional tracking entries should have been added 313 | self.assertTrue(len(item.getTrackingEntries()) > n_tracking) 314 | 315 | # Test error conditions 316 | for v in [-1, 'gg', None]: 317 | with self.assertRaises(requests.exceptions.HTTPError): 318 | item.addStock(v) 319 | with self.assertRaises(requests.exceptions.HTTPError): 320 | item.removeStock(v) 321 | 322 | def test_transfer(self): 323 | """Unit test for 'transfer' action""" 324 | 325 | item = StockItem(self.api, pk=2) 326 | 327 | n_tracking = len(item.getTrackingEntries()) 328 | 329 | # Transfer to a StockLocation instance 330 | location = StockLocation(self.api, pk=1) 331 | 332 | item.transferStock(location) 333 | item.reload() 334 | self.assertEqual(item.location, 1) 335 | 336 | # Transfer with a location ID 337 | item.transferStock(2) 338 | item.reload() 339 | self.assertEqual(item.location, 2) 340 | 341 | # 2 additional tracking entries should have been added 342 | self.assertTrue(len(item.getTrackingEntries()) > n_tracking) 343 | 344 | # Attempt to transfer to an invalid location 345 | for loc in [-1, 'qqq', 99999, None]: 346 | with self.assertRaises(requests.exceptions.HTTPError): 347 | item.transferStock(loc) 348 | 349 | # Attempt to transfer with an invalid quantity 350 | for q in [-1, None, 'hhhh']: 351 | with self.assertRaises(requests.exceptions.HTTPError): 352 | item.transferStock(loc, quantity=q) 353 | 354 | def test_transfer_multiple(self): 355 | """Test transfer of *multiple* items""" 356 | 357 | items = StockItem.list(self.api, in_stock=True, location=1) 358 | 359 | self.assertTrue(len(items) > 1) 360 | 361 | # Construct data to send 362 | data = [] 363 | 364 | for item in items: 365 | data.append({ 366 | 'pk': item.pk, 367 | 'quantity': item.quantity, 368 | }) 369 | 370 | # Transfer all items into a new location 371 | StockItem.transferStockItems(self.api, data, 2) 372 | 373 | for item in items: 374 | item.reload() 375 | self.assertEqual(item.location, 2) 376 | 377 | # Transfer back to the original location 378 | StockItem.transferStockItems(self.api, data, 1) 379 | 380 | for item in items: 381 | item.reload() 382 | self.assertEqual(item.location, 1) 383 | 384 | history = item.getTrackingEntries() 385 | 386 | self.assertTrue(len(history) >= 2) 387 | self.assertEqual(history[0].label, 'Location changed') 388 | 389 | def test_assign_stock(self): 390 | """Test assigning stock to customer""" 391 | 392 | items = StockItem.list(self.api) 393 | self.assertTrue(len(items) > 1) 394 | 395 | # Get first Company which is a customer 396 | customer = company.Company.list(self.api, is_customer=True)[0] 397 | 398 | # Get first part which is salable 399 | assignpart = part.Part.list(self.api, salable=True)[0] 400 | 401 | # Create stock item which can be assigned 402 | assignitem = StockItem.create( 403 | self.api, 404 | { 405 | "part": assignpart.pk, 406 | "quantity": 10, 407 | } 408 | ) 409 | 410 | # Assign the item 411 | assignitem.assignStock(customer=customer, notes='Sell on the side') 412 | 413 | # Reload the item 414 | assignitem.reload() 415 | 416 | # Check the item is assigned 417 | self.assertTrue(assignitem.customer == customer.pk) 418 | 419 | def test_install_stock(self): 420 | """Test install and uninstall a stock item from another""" 421 | 422 | items = StockItem.list(self.api, available=True) 423 | 424 | self.assertTrue(len(items) > 1) 425 | 426 | # get a parent and a child part 427 | parent_part = part.Part.list( 428 | self.api, 429 | trackable=True, 430 | assembly=True, 431 | has_stock=True 432 | )[0] 433 | parent_stock = parent_part.getStockItems()[0] 434 | child_stock = items[0] 435 | child_part = child_stock.getPart() 436 | 437 | # make sure the child is in the bom of the parent 438 | 439 | items = parent_part.getBomItems(search=child_part.name) 440 | if not items: 441 | part.BomItem.create( 442 | self.api, { 443 | 'part': parent_part.pk, 444 | 'sub_part': child_part.pk, 445 | 'quantity': 1 446 | } 447 | ) 448 | 449 | self.assertIsNone(child_stock.belongs_to) 450 | 451 | # Attempt to install with incorrect quantity 452 | with self.assertRaises(requests.exceptions.HTTPError): 453 | parent_stock.installStock(child_stock, quantity=child_stock.quantity * 2) 454 | 455 | with self.assertRaises(requests.exceptions.HTTPError): 456 | parent_stock.installStock(child_stock, quantity=-100) 457 | 458 | # install the *entire* child item into the parent 459 | parent_stock.installStock(child_stock, quantity=child_stock.quantity) 460 | child_stock.reload() 461 | self.assertIsNotNone(child_stock.belongs_to) 462 | self.assertEqual(child_stock.belongs_to, parent_stock.pk) 463 | 464 | # and uninstall it again 465 | location = StockLocation.list(self.api)[0] 466 | child_stock.uninstallStock(location) 467 | 468 | # check if the location is set correctly to confirm the uninstall 469 | child_stock.reload() 470 | new_location = child_stock.getLocation() 471 | self.assertTrue(new_location.pk == location.pk) 472 | --------------------------------------------------------------------------------