Merge branch 'master' into matmair/issue11460
|
|
@ -174,7 +174,7 @@ jobs:
|
|||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # pin@v4.0.0
|
||||
- name: Set up cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # pin@v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # pin@v4.1.2
|
||||
- name: Check if Dockerhub login is required
|
||||
id: docker_login
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
invoke dev.setup-test -i
|
||||
- name: Create Plugin Data
|
||||
run: |
|
||||
pip install -U inventree-dummy-app-plugin
|
||||
pip install -U inventree-dummy-app-plugin==0.1.0
|
||||
invoke migrate
|
||||
cd src/backend/InvenTree && python manage.py create_dummy_data
|
||||
- name: Export Postgres Dataset
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ on:
|
|||
|
||||
env:
|
||||
python_version: 3.11
|
||||
node_version: 20
|
||||
node_version: 24
|
||||
# The OS version must be set per job
|
||||
server_start_sleep: 60
|
||||
|
||||
|
|
@ -101,8 +101,8 @@ jobs:
|
|||
echo "submit-performance=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
pre-commit:
|
||||
name: Style [pre-commit]
|
||||
code-style:
|
||||
name: Style [prek]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
|
@ -116,8 +116,8 @@ jobs:
|
|||
with:
|
||||
python-version: ${{ env.python_version }}
|
||||
cache: "pip"
|
||||
- name: Run pre-commit Checks
|
||||
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # pin@v3.0.1
|
||||
- name: Run pre commit hook Checks
|
||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # pin@v2
|
||||
- name: Check Version
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
|
||||
|
|
@ -126,7 +126,7 @@ jobs:
|
|||
typecheck:
|
||||
name: Style [Typecheck]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [paths-filter, pre-commit]
|
||||
needs: [code-style, paths-filter]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
steps:
|
||||
|
|
@ -222,7 +222,7 @@ jobs:
|
|||
echo "Downloaded api.yaml"
|
||||
- name: Running OpenAPI Spec diff action
|
||||
id: breaking_changes
|
||||
uses: oasdiff/oasdiff-action/diff@f8cb9308b42121e793f835bd14c0b8090420430c # pin@main
|
||||
uses: oasdiff/oasdiff-action/diff@6147a58e5d1249a12f42fc864ab791d571a30015 # pin@main
|
||||
with:
|
||||
base: "api.yaml"
|
||||
revision: "src/backend/InvenTree/schema.yml"
|
||||
|
|
@ -310,7 +310,7 @@ jobs:
|
|||
name: Tests - inventree-python
|
||||
runs-on: ${{ needs.paths-filter.outputs.runner-perf }}
|
||||
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
needs: ["code-style", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true' || needs.paths-filter.outputs.performance == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -329,7 +329,7 @@ jobs:
|
|||
INVENTREE_SITE_URL: http://127.0.0.1:12345
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
node_version: '>=20.19.6'
|
||||
node_version: '>=24'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
|
|
@ -363,7 +363,7 @@ jobs:
|
|||
pip install .
|
||||
if: needs.paths-filter.outputs.submit-performance == 'true'
|
||||
- name: Performance Reporting
|
||||
uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # pin@v4
|
||||
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # pin@v4.15.1
|
||||
# check if we are in inventree/inventree - reporting only works in that OIDC context
|
||||
if: github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true'
|
||||
with:
|
||||
|
|
@ -374,7 +374,7 @@ jobs:
|
|||
name: Tests - DB [SQLite] + Coverage ${{ matrix.python_version }}
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
needs: ["code-style", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
continue-on-error: true # continue if a step fails so that coverage gets pushed
|
||||
strategy:
|
||||
|
|
@ -415,7 +415,7 @@ jobs:
|
|||
path: .coverage
|
||||
retention-days: 14
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
@ -426,7 +426,7 @@ jobs:
|
|||
name: Tests - Performance
|
||||
runs-on: ${{ needs.paths-filter.outputs.runner-perf }}
|
||||
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
needs: ["code-style", "paths-filter"]
|
||||
# check if we are in inventree/inventree - reporting only works in that OIDC context
|
||||
if: (needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true') && github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true'
|
||||
permissions:
|
||||
|
|
@ -452,9 +452,9 @@ jobs:
|
|||
update: true
|
||||
npm: true
|
||||
env:
|
||||
node_version: '>=20.19.0'
|
||||
node_version: '>=24'
|
||||
- name: Performance Reporting
|
||||
uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # pin@v4
|
||||
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # pin@v4.15.1
|
||||
with:
|
||||
mode: walltime
|
||||
run: inv dev.test --pytest
|
||||
|
|
@ -462,7 +462,7 @@ jobs:
|
|||
postgres:
|
||||
name: Tests - DB [PostgreSQL]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
needs: ["code-style", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
env:
|
||||
|
|
@ -512,7 +512,7 @@ jobs:
|
|||
name: Tests - DB [MySQL]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
needs: ["code-style", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
env:
|
||||
|
|
@ -597,7 +597,7 @@ jobs:
|
|||
- name: Run Tests
|
||||
run: invoke dev.test --check --migrations --report --coverage --translations
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
@ -668,7 +668,7 @@ jobs:
|
|||
name: Tests - Web UI
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
needs: ["code-style", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
services:
|
||||
postgres:
|
||||
|
|
@ -734,7 +734,7 @@ jobs:
|
|||
- name: Report coverage
|
||||
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: inventree/InvenTree
|
||||
|
|
@ -779,7 +779,7 @@ jobs:
|
|||
zizmor:
|
||||
name: Security [Zizmor]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
needs: ["code-style", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
permissions:
|
||||
|
|
@ -789,13 +789,5 @@ jobs:
|
|||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # pin@v2
|
||||
- name: Run zizmor
|
||||
run: uvx zizmor --format sarif . > results.sarif
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Upload SARIF file
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # pin@v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: zizmor
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
|
|
|
|||
|
|
@ -78,26 +78,21 @@ jobs:
|
|||
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
|
||||
|
||||
- name: Upload frontend
|
||||
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # pin@2.11.5
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: src/backend/InvenTree/web/static/frontend-build.zip
|
||||
asset_name: frontend-build.zip
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
run: gh release upload ${REF} src/backend/InvenTree/web/static/frontend-build.zip#frontend-build.zip
|
||||
env:
|
||||
REF: ${{ github.ref_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Upload frontend to artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: src/backend/InvenTree/web/static/frontend-build.zip
|
||||
- name: Upload Attestation
|
||||
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # pin@2.11.5
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
asset_name: frontend-build.intoto.jsonl
|
||||
file: ${{ steps.attest.outputs.bundle-path}}
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
run: gh release upload ${REF} ${BUNDLE_PATH}#frontend-build.intoto.jsonl
|
||||
env:
|
||||
REF: ${{ github.ref_name }}
|
||||
BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path}}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-24.04
|
||||
|
|
@ -134,13 +129,10 @@ jobs:
|
|||
cd docs/site
|
||||
zip -r docs-html.zip *
|
||||
- name: Publish documentation
|
||||
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # pin@2.11.5
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: docs/site/docs-html.zip
|
||||
asset_name: docs-html.zip
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
run: gh release upload ${REF} docs/site/docs-html.zip#docs-html.zip
|
||||
env:
|
||||
REF: ${{ github.ref_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-pkgr:
|
||||
if: github.repository == 'inventree/InvenTree'
|
||||
|
|
@ -244,10 +236,9 @@ jobs:
|
|||
channel: ${{ env.pkg_channel }}
|
||||
file: ${{ steps.package.outputs.package_path }}
|
||||
- name: Publish to artifact
|
||||
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # pin@2.11.5
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.package.outputs.package_path }}
|
||||
asset_name: ${{ matrix.target }}-{{ steps.setup.outputs.version }}.tar.gz
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
run: gh release upload ${REF} ${PACKAGE_PATH}#${PACKAGE_NAME}
|
||||
env:
|
||||
REF: ${{ github.ref_name }}
|
||||
PACKAGE_PATH: ${{ steps.package.outputs.package_path }}
|
||||
PACKAGE_NAME: ${{ matrix.target }}-{{ steps.setup.outputs.version }}.tar.gz
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,6 @@ jobs:
|
|||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ on:
|
|||
|
||||
env:
|
||||
python_version: 3.11
|
||||
node_version: 20
|
||||
node_version: 24
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
echo "Resetting to HEAD~"
|
||||
git reset HEAD~ || true
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@7ca9c452bfe9197d3bb7fa83a4d7e2b0c9ae835d # pin@v2
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # pin@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ repos:
|
|||
exclude: mkdocs.yml
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.1
|
||||
rev: v0.15.12
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
args: [--preview]
|
||||
|
|
@ -29,7 +29,7 @@ repos:
|
|||
--preview
|
||||
]
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.10.2
|
||||
rev: 0.11.12
|
||||
hooks:
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements-dev.in
|
||||
|
|
@ -48,15 +48,15 @@ repos:
|
|||
args: [src/backend/requirements.in, -o, src/backend/requirements-3.14.txt, --python-version=3.14, -c, src/backend/requirements.txt]
|
||||
files: src/backend/requirements\.(in|txt)$
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements.txt
|
||||
name: pip-compile contrib/dev_reqs/requirements.txt
|
||||
args: [contrib/dev_reqs/requirements.in, -o, contrib/dev_reqs/requirements.txt, -c, src/backend/requirements.txt]
|
||||
files: contrib/dev_reqs/requirements\.(in|txt)$
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements.txt
|
||||
name: pip-compile docs/requirements.txt
|
||||
args: [docs/requirements.in, -o, docs/requirements.txt, -c, src/backend/requirements.txt]
|
||||
files: docs/requirements\.(in|txt)$
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements.txt
|
||||
name: pip-compile contrib/container/requirements.txt
|
||||
args: [contrib/container/requirements.in, -o, contrib/container/requirements.txt, --python-version=3.14, -c, src/backend/requirements.txt]
|
||||
files: contrib/container/requirements\.(in|txt)$
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
|
|
@ -64,7 +64,7 @@ repos:
|
|||
hooks:
|
||||
- id: djlint-django
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.1
|
||||
rev: v2.4.2
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies:
|
||||
|
|
@ -79,13 +79,13 @@ repos:
|
|||
src/frontend/vite.config.ts |
|
||||
)$
|
||||
- repo: https://github.com/biomejs/pre-commit
|
||||
rev: v2.3.15
|
||||
rev: v2.4.14
|
||||
hooks:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@1.9.4"]
|
||||
files: ^src/frontend/.*\.(js|ts|tsx)$
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.30.0
|
||||
rev: v8.30.1
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
language_version: 1.25.4
|
||||
|
|
|
|||
20
CHANGELOG.md
|
|
@ -9,15 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Breaking Changes
|
||||
|
||||
- [#9604](https://github.com/inventree/InvenTree/pull/9604) - refactors user API endpoint to be less ambiguous
|
||||
- [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself
|
||||
|
||||
### Added
|
||||
|
||||
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
|
||||
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
|
||||
- [#11920](https://github.com/inventree/InvenTree/pull/11920) adds support for renaming attachments after they have been uploaded. This includes both backend and frontend changes, allowing users to rename attachments via the API or through the UI.
|
||||
- [#11914](https://github.com/inventree/InvenTree/pull/11914) adds a "maximum_stock" field to the Part model, allowing users to specify a maximum preferred stock level for each part. This is used in conjunction with the existing "minimum_stock" field to allow users to define a preferred stock range for each part. The "high_stock" filter has also been added to the Part API endpoint, allowing users to filter parts which are above their maximum stock level.
|
||||
- [#11631](https://github.com/inventree/InvenTree/pull/11631) adds "raw_amount" field to the BomItem model, allowing BOM quantities to account for the units of measure of the underlying part.
|
||||
- [#11872](https://github.com/inventree/InvenTree/pull/11872) adds a global setting to allow or disallow the deletion of serialized stock items.
|
||||
- [#11861](https://github.com/inventree/InvenTree/pull/11861) adds support for bulk-replacing a component in multiple BOMs simultaneously
|
||||
- [#11853](https://github.com/inventree/InvenTree/pull/11853) adds BOM comparison functionality, allowing users to compare the BOM of one assembly with another assembly.
|
||||
- [#11809](https://github.com/inventree/InvenTree/pull/11809) adds multi-level subassembly display mode to the BOM table, allowing users to view multiple levels of subassemblies in a single table view. This is an optional display mode which can be toggled on or off by the user.
|
||||
- [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog.
|
||||
- [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes
|
||||
- [#11788](https://github.com/inventree/InvenTree/pull/11788) adds support for custom permissions checks on database models defined in plugins. If a model defines a `check_user_permission` classmethod, this will be called to determine if a user has permission to view the model. This is required for plugin models which do not have the required ruleset definitions for the standard permission system.
|
||||
- [#9570](https://github.com/inventree/InvenTree/pull/9570) adds support for defining primary actions via plugins
|
||||
|
||||
### Changed
|
||||
|
||||
- [#11990](https://github.com/inventree/InvenTree/pull/11990) build output operations performed via the API now offload the work to a background task, and now return a task ID which can be used to monitor the progress of the task. This allows for better performance and responsiveness when performing build output operations, as the work is performed asynchronously in the background.
|
||||
- [#11825](https://github.com/inventree/InvenTree/pull/11825) adds a new "bom" ruleset and associated permissions for BOM management, separate from the "part" ruleset which remains focused on part management. This allows for more granular control over user permissions, allowing users to have different levels of access to part management and BOM management functionality.
|
||||
- [#11816](https://github.com/inventree/InvenTree/pull/11816) makes the `issued_by` field on the `Build` API read only, and instead sets the `issued_by` field to the current user when a build is created. This change was made to ensure that the `issued_by` field accurately reflects the user who created the build, and to prevent users from setting this field to an arbitrary value when creating or updating a build.
|
||||
|
||||
### Removed
|
||||
|
||||
- [#11962](https://github.com/inventree/InvenTree/pull/11962) removes the "remote_image" field from the Part API endpoint, which (previously) allowed the user to specify a remote URL for an image to be downloaded and associated with the part. This field was removed due to security concerns around downloading images from arbitrary URLs. If you were using this field in an external client application, you will need to update your application to use the new "download_image_from_url" API endpoint instead.
|
||||
|
||||
## 1.3.0 - 2026-04-11
|
||||
|
||||
### Breaking Changes
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ InvenTree is designed to be **extensible**, and provides multiple options for **
|
|||
<ul>
|
||||
<li><a href="https://www.postgresql.org/">PostgreSQL</a></li>
|
||||
<li><a href="https://www.mysql.com/">MySQL</a></li>
|
||||
<li><a href="https://www.mariadb.org/">MariaDB</a></li>
|
||||
<li><a href="https://www.sqlite.org/">SQLite</a></li>
|
||||
<li><a href="https://redis.io/">Redis</a></li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
ignore:
|
||||
- "src/backend/InvenTree/**/test_migrations.py"
|
||||
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
# - Monitors source files for any changes, and live-reloads server
|
||||
|
||||
# Base image last bumped 2026-03-20
|
||||
FROM python:3.14-slim-trixie@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca AS inventree_base
|
||||
FROM python:3.14.5-slim-trixie@sha256:c845af9399020c7e562969a13689e929074a10fd057acd1b1fad06a2fb068e97 AS inventree_base
|
||||
|
||||
# Build arguments for this image
|
||||
ARG commit_tag=""
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ asgiref==3.11.1 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django
|
||||
django==5.2.13 \
|
||||
--hash=sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a \
|
||||
--hash=sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4
|
||||
django==5.2.14 \
|
||||
--hash=sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2 \
|
||||
--hash=sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r contrib/container/requirements.in
|
||||
|
|
@ -17,9 +17,9 @@ django-auth-ldap==5.3.0 \
|
|||
--hash=sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19 \
|
||||
--hash=sha256:aa880415983149b072f876d976ef8ec755a438090e176817998263a6ed9e1038
|
||||
# via -r contrib/container/requirements.in
|
||||
gunicorn==25.3.0 \
|
||||
--hash=sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660 \
|
||||
--hash=sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889
|
||||
gunicorn==26.0.0 \
|
||||
--hash=sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc \
|
||||
--hash=sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r contrib/container/requirements.in
|
||||
|
|
@ -53,9 +53,9 @@ mysqlclient==2.2.8 \
|
|||
--hash=sha256:9bed7c8d3b629bdc09e17fb628d5b3b0a5fd1f12b09432b464b9126c727bedc0 \
|
||||
--hash=sha256:a81f5e12f8d05439709cb02fba97f9f76d1a6c528164f2260d8798fec969e300
|
||||
# via -r contrib/container/requirements.in
|
||||
packaging==26.0 \
|
||||
--hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \
|
||||
--hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# gunicorn
|
||||
|
|
@ -236,28 +236,28 @@ typing-extensions==4.15.0 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# psycopg-pool
|
||||
uv==0.10.12 \
|
||||
--hash=sha256:006812a086fce03d230fc987299f7295c7a73d17a1f1c17de1d1f327826f8481 \
|
||||
--hash=sha256:101481a1f48db6becf219914a591a588c0b3bfd05bef90768a5d04972bd6455e \
|
||||
--hash=sha256:2ace05115bd9ee1b30d341728257fe051817c4c0a652c085c90d4bd4fb0bc8f2 \
|
||||
--hash=sha256:2bb5893d79179727253e4a283871a693d7773c662a534fb897aa65496aa35765 \
|
||||
--hash=sha256:2c21e1b36c384f75dd3fd4a818b04871158ce115efff0bb4fdcd18ba2df7bd48 \
|
||||
--hash=sha256:2c5dfc7560453186e911c8c2e4ce95cd1c91e1c5926c3b34c5a825a307217be9 \
|
||||
--hash=sha256:384b7f36a1ae50efe5f50fe299f276a83bf7acc8b7147517f34e27103270f016 \
|
||||
--hash=sha256:551f799d53e397843b6cde7e3c61de716fb487da512a21a954b7d0cbc06967e0 \
|
||||
--hash=sha256:6727e3a0208059cd4d621684e580d5e254322dacbd806e0d218360abd0d48a68 \
|
||||
--hash=sha256:7099bdefffbe2df81accad52579657b8f9f870170caa779049c9fd82d645c9b3 \
|
||||
--hash=sha256:76ebe11572409dfbe20ec25a823f9bc8781400ece5356aa33ec44903af7ec316 \
|
||||
--hash=sha256:8dc352c93a47a4760cf824c31c55ce26511af780481e8f67c796d2779acaa928 \
|
||||
--hash=sha256:a5afe619e8a861fe4d49df8e10d2c6963de0dac6b79350c4832bf3366c8496cf \
|
||||
--hash=sha256:b9ca1d264059cb016c853ebbc4f21c72d983e0f347c927ca29e283aec2f596cf \
|
||||
--hash=sha256:bd84379292e3c1a1bf0a05847c7c72b66bb581dccf8da1ef94cc82bf517efa7c \
|
||||
--hash=sha256:be85acae8f31c68311505cd96202bad43165cbd7be110c59222f918677e93248 \
|
||||
--hash=sha256:cca36540d637c80d11d8a44a998a068355f0c78b75ec6b0f152ecbf89dfdd67b \
|
||||
--hash=sha256:e0f0ef58f0ba6fbfaf5f91b67aad6852252c49b8f78015a2a5800cf74c7538d5 \
|
||||
--hash=sha256:fa722691c7ae5c023778ad0b040ab8619367bcfe44fd0d9e05a58751af86cdf8
|
||||
uv==0.11.7 \
|
||||
--hash=sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce \
|
||||
--hash=sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9 \
|
||||
--hash=sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924 \
|
||||
--hash=sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405 \
|
||||
--hash=sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d \
|
||||
--hash=sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4 \
|
||||
--hash=sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d \
|
||||
--hash=sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71 \
|
||||
--hash=sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1 \
|
||||
--hash=sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9 \
|
||||
--hash=sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0 \
|
||||
--hash=sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a \
|
||||
--hash=sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605 \
|
||||
--hash=sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d \
|
||||
--hash=sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c \
|
||||
--hash=sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f \
|
||||
--hash=sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c \
|
||||
--hash=sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9 \
|
||||
--hash=sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b
|
||||
# via -r contrib/container/requirements.in
|
||||
wheel==0.46.3 \
|
||||
--hash=sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d \
|
||||
--hash=sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803
|
||||
wheel==0.47.0 \
|
||||
--hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced \
|
||||
--hash=sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3
|
||||
# via -r contrib/container/requirements.in
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Packages needed for CI/packages
|
||||
requests==2.33.1
|
||||
requests==2.34.2
|
||||
pyyaml==6.0.3
|
||||
jc==1.25.6
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile contrib/dev_reqs/requirements.in -o contrib/dev_reqs/requirements.txt -c src/backend/requirements.txt
|
||||
certifi==2026.2.25 \
|
||||
--hash=sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa \
|
||||
--hash=sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7
|
||||
certifi==2026.4.22 \
|
||||
--hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \
|
||||
--hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
|
|
@ -139,9 +139,9 @@ charset-normalizer==3.4.7 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
idna==3.11 \
|
||||
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
|
||||
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
|
||||
idna==3.15 \
|
||||
--hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 \
|
||||
--hash=sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
|
|
@ -230,9 +230,9 @@ pyyaml==6.0.3 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r contrib/dev_reqs/requirements.in
|
||||
requests==2.33.1 \
|
||||
--hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \
|
||||
--hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a
|
||||
requests==2.34.2 \
|
||||
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
|
||||
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r contrib/dev_reqs/requirements.in
|
||||
|
|
@ -240,9 +240,9 @@ ruamel-yaml==0.19.1 \
|
|||
--hash=sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93 \
|
||||
--hash=sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993
|
||||
# via jc
|
||||
urllib3==2.6.3 \
|
||||
--hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \
|
||||
--hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4
|
||||
urllib3==2.7.0 \
|
||||
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
|
||||
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ Each user is assigned an authentication token which can be used to access the AP
|
|||
|
||||
If a user does not know their access token, it can be requested via the API interface itself, using a basic authentication request.
|
||||
|
||||
To obtain a valid token, perform a GET request to `/api/user/token/`. No data are required, but a valid username / password combination must be supplied in the authentication headers.
|
||||
To obtain a valid token, perform a GET request to `/api/user/me/token/`. No data are required, but a valid username / password combination must be supplied in the authentication headers.
|
||||
|
||||
!!! info "Credentials"
|
||||
Ensure that a valid username:password combination are supplied as basic authorization headers.
|
||||
|
|
@ -146,7 +146,7 @@ r:delete:stock
|
|||
Users can only perform REST API actions which align with their assigned [role permissions](../settings/permissions.md#roles).
|
||||
Once a user has *authenticated* via the API, a list of the available roles can be retrieved from:
|
||||
|
||||
`/api/user/roles/`
|
||||
`/api/user/me/roles/`
|
||||
|
||||
For example, when accessing the API from a *superuser* account:
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ The API schema as documented below is generated using the [drf-spectactular](htt
|
|||
|
||||
## API Version
|
||||
|
||||
This documentation is for API version: `449`
|
||||
This documentation is for API version: `459`
|
||||
|
||||
!!! tip "API Schema History"
|
||||
We track API schema changes, and provide a snapshot of each API schema version in the [API schema repository](https://github.com/inventree/schema/).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
title: Build Orders
|
||||
---
|
||||
|
||||
## Build Order List
|
||||
|
||||
The build order list display lists all build orders:
|
||||
|
||||
{{ image("app/build_list.png", "Build order list") }}
|
||||
|
||||
Select an individual build order to display the detail view for that order.
|
||||
|
||||
### Filtering
|
||||
|
||||
Displayed build orders can be subsequently filtered using the search input at the top of the screen
|
||||
|
||||
## Build Order Detail
|
||||
|
||||
{{ image("app/build_detail.png", "Build order detail") }}
|
||||
|
||||
### Edit Build Order Details
|
||||
|
||||
From the detail view, select the *Edit* button in the top-right of the screen. This opens the build order editing display.
|
||||
|
||||
### Required Parts Tab
|
||||
|
||||
The *Required Parts* tab shows the parts required to complete this build order:
|
||||
|
||||
{{ image("app/build_required_parts.png", "Build order required parts") }}
|
||||
|
||||
### Build Outputs Tab
|
||||
|
||||
The *Build Outputs* tab shows the stock items which have been produced as part of this build order:
|
||||
|
||||
{{ image("app/build_outputs.png", "Build order outputs") }}
|
||||
|
|
@ -30,7 +30,7 @@ The *Line Items* tab shows the line items associated with this purchase order:
|
|||
|
||||
{{ image("app/po_lines.png", "Purchase order line items") }}
|
||||
|
||||
Long press on a particular line item to receive the item into stock.
|
||||
Select a particular line item to view the details for that line, and receive the line item into stock.
|
||||
|
||||
### Stock Items
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
|
@ -16,3 +16,36 @@ Parameters can be associated with various InvenTree models.
|
|||
Any model which supports attachments will have an "Attachments" tab on its detail page. This tab displays all attachments associated with that object:
|
||||
|
||||
{{ image("concepts/attachments-tab.png", "Order Attachments Example") }}
|
||||
|
||||
## Attachments Types
|
||||
|
||||
The following types of attachments are supported:
|
||||
|
||||
### File Attachments
|
||||
|
||||
File attachments allow users to upload files directly to InvenTree. These files are stored on the server and can be downloaded or viewed by users with appropriate permissions.
|
||||
|
||||
### Image Thumbnails
|
||||
|
||||
When a file attachment is uploaded, InvenTree automatically determines whether the file is a valid image. If it is, a thumbnail is generated and stored alongside the attachment.
|
||||
|
||||
- The thumbnail is created with a reduced image size, while preserving the original aspect ratio.
|
||||
- Thumbnail generation is performed in the background after upload.
|
||||
- The `is_image` flag on the attachment record is set to `True` for valid images, and `False` for all other file types.
|
||||
- If the uploaded file has an image extension but contains invalid or corrupt image data, no thumbnail is generated and `is_image` remains `False`.
|
||||
- Link attachments (external URLs) are never assigned a thumbnail.
|
||||
|
||||
!!! info "Supported Formats"
|
||||
Any image format recognised by the [Pillow](https://pillow.readthedocs.io/) library (e.g. PNG, JPEG, GIF, BMP, WEBP) will be treated as a valid image and have a thumbnail generated automatically.
|
||||
|
||||
### Link Attachments
|
||||
|
||||
Link attachments allow users to associate external URLs with an object. This can be useful for linking to external documentation, resources, or other relevant web content.
|
||||
|
||||
## Adding Attachments
|
||||
|
||||
To add an attachment to an object, navigate to the object's detail page and click on the "Attachments" tab. From there, you can click the "Add attachment" button to upload a file or the "Add external link" button to add a link.
|
||||
|
||||
### Renaming Attachments
|
||||
|
||||
Once a file attachment has been uploaded, it can be renamed by clicking the "Edit" action associated with the attachment. This allows you to change the filename without needing to re-upload the file. The system will handle renaming the file on the server and updating the database record accordingly.
|
||||
|
|
|
|||
|
|
@ -4,16 +4,70 @@ title: Custom States
|
|||
|
||||
## Custom States
|
||||
|
||||
Several models within InvenTree support the use of custom states. The custom states are display only - the business logic is not affected by the state.
|
||||
Several models within InvenTree support the use of *custom states*. Custom states extend the built-in status system by adding extra labels and colours that are displayed in the user interface.
|
||||
|
||||
States can be added in the [Admin Center](../settings/admin.md#admin-center) under the "Custom States" section. Each state has a name, label and a color that are used to display the state in the user interface. Changes to these settings will only be reflected in the user interface after a full reload of the interface.
|
||||
!!! info "Display Only"
|
||||
Custom states affect display only — they do not add new workflow steps or change business logic. Every custom state is mapped to an existing built-in state (its *logical key*), and the system uses that built-in state for all decisions such as availability counts, order transitions, and filtering.
|
||||
|
||||
States need to be assigned to a model, state (for example status on a StockItem) and a logical key - that will be used for business logic. These 3 values combined need to be unique throughout the system.
|
||||
### Example
|
||||
|
||||
Custom states can be defined for the following models:
|
||||
Suppose you want to track stock items that are physically present and available, but are waiting for a quality inspection before use. The built-in `OK` status is the closest match — the item is available — but you want it to appear distinctly in the interface.
|
||||
|
||||
- [Stock Item](../stock/index.md)
|
||||
- [Build Order](../manufacturing/build.md)
|
||||
- [Purchase Order](../purchasing/purchase_order.md)
|
||||
- [Sales Order](../sales/sales_order.md)
|
||||
- [Return Order](../sales/return_order.md)
|
||||
You would create a custom state:
|
||||
|
||||
- **Logical key**: `OK` — the system treats the item as available stock
|
||||
- **Label**: `Awaiting Inspection` — shown in the interface instead of "OK"
|
||||
- **Colour**: `warning` — displayed in amber to draw attention
|
||||
|
||||
The item is counted as available stock in all reports and filters, but is visually distinguished from items with a standard `OK` status.
|
||||
|
||||
## Managing Custom States
|
||||
|
||||
Custom states are managed in the [Admin Center](../settings/admin.md#admin-center) under the *Custom States* section.
|
||||
|
||||
!!! warning "Page Reload Required"
|
||||
Changes to custom states are only reflected in the user interface after a full page reload.
|
||||
|
||||
## State Fields
|
||||
|
||||
When creating a custom state, the following fields must be provided:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Model** | The model type this state applies to (e.g. *Stock Item*, *Build Order*) |
|
||||
| **Reference Status** | The status class being extended (e.g. `StockStatus`, `BuildStatus`) |
|
||||
| **Logical Key** | The built-in status value this custom state maps to for business logic |
|
||||
| **Key** | A unique integer that identifies this custom state in the database |
|
||||
| **Name** | An uppercase Python identifier for this state (e.g. `AWAITING_INSPECTION`) |
|
||||
| **Label** | The human-readable text displayed in the interface |
|
||||
| **Colour** | The badge colour used to display the state |
|
||||
|
||||
### Key
|
||||
|
||||
The *Key* is the integer value stored in the database when this custom state is active. It must satisfy all of the following:
|
||||
|
||||
- Must be a positive integer
|
||||
- Must not be equal to the *Logical Key*
|
||||
- Must not conflict with any existing built-in status values for the selected model
|
||||
|
||||
### Name
|
||||
|
||||
The *Name* field is used internally to identify the state. It must:
|
||||
|
||||
- Be uppercase (e.g. `AWAITING_INSPECTION`, not `awaiting_inspection`)
|
||||
- Be a valid Python identifier (letters, digits, underscores; no spaces or hyphens)
|
||||
- Not conflict with any existing status names for the selected model
|
||||
|
||||
### Colours
|
||||
|
||||
The following colour values are available:
|
||||
|
||||
| Colour | Appearance |
|
||||
|--------|------------|
|
||||
| `primary` | Blue |
|
||||
| `secondary` | Grey |
|
||||
| `success` | Green |
|
||||
| `warning` | Amber |
|
||||
| `danger` | Red |
|
||||
| `info` | Cyan |
|
||||
| `dark` | Dark grey / black |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
title: Exporting Data
|
||||
---
|
||||
|
||||
## Exporting Data
|
||||
|
||||
InvenTree provides data export functionality for a variety of data types. Most data tables in the web interface include a *Download* button in the table toolbar, which allows the currently displayed data to be exported to a file.
|
||||
|
||||
!!! info "Filtered Data"
|
||||
The export reflects the data currently visible in the table — any active filters, search terms, or sort order are carried through to the exported file. To export the full dataset, clear all filters before exporting.
|
||||
|
||||
!!! info "Paginated Data"
|
||||
In the user interface, data tables are paginated to improve performance. When exporting data, the export will include **all** records that match the current filters and search terms, not just the records visible on the current page.
|
||||
|
||||
## How to Export
|
||||
|
||||
**Step 1** — In any table view, click the {{ icon("cloud-download") }} *Download* button in the table toolbar:
|
||||
|
||||
{{ image("admin/export.png", "Download button") }}
|
||||
|
||||
**Step 2** — An export dialog is displayed. Select the desired *Export Format* and *Export Plugin*, then click *Export*:
|
||||
|
||||
{{ image("admin/export_options.png", "Export dialog") }}
|
||||
|
||||
**Step 3** — The export runs in the background. A loading indicator is shown while the export is being processed. When the export is complete, the file is automatically downloaded to your browser.
|
||||
|
||||
## Supported File Formats
|
||||
|
||||
The following file formats are available for export:
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| CSV | Comma-separated values. Portable plain-text format, compatible with most tools. |
|
||||
| Excel | Microsoft Excel format (`.xlsx`). Suitable for direct use in spreadsheet applications. |
|
||||
| TSV | Tab-separated values. Similar to CSV but uses tab characters as delimiters. |
|
||||
|
||||
## Export Plugins
|
||||
|
||||
InvenTree uses a plugin-based export system. The export dialog lists all plugins that are available for the data type being exported. Selecting a different plugin may provide additional export options or a different output format.
|
||||
|
||||
### Built-in Exporters
|
||||
|
||||
InvenTree includes the following built-in export plugins:
|
||||
|
||||
| Plugin | Description |
|
||||
|--------|-------------|
|
||||
| [InvenTree Exporter](../plugins/builtin/inventree_exporter.md) | General-purpose exporter for any tabulated dataset. Always enabled. |
|
||||
| [BOM Exporter](../plugins/builtin/bom_exporter.md) | Custom exporter for Bill of Materials data, with additional BOM-specific options. |
|
||||
| [Parameter Exporter](../plugins/builtin/parameter_exporter.md) | Exports part data including all associated custom parameter values as additional columns. |
|
||||
| [Stocktake Exporter](../plugins/builtin/stocktake_exporter.md) | Exports a comprehensive stock-level summary for parts, with optional pricing and variant data. |
|
||||
|
||||
Custom export plugins can also be developed using the [DataExportMixin](../plugins/mixins/export.md).
|
||||
|
||||
## API Export
|
||||
|
||||
Data can also be exported programmatically via the InvenTree REST API. To trigger an export, perform a `GET` request against any list endpoint with the following query parameters:
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `export` | Set to `true` to trigger an export |
|
||||
| `export_format` | File format: `csv`, `xlsx`, or `tsv` (default: `csv`) |
|
||||
| `export_plugin` | Slug of the export plugin to use (default: `inventree-exporter`) |
|
||||
|
||||
Additional `export_*` parameters may be accepted depending on the plugin selected.
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
GET /api/part/?export=true&export_format=xlsx&export_plugin=inventree-exporter
|
||||
```
|
||||
|
||||
Refer to the [API documentation](../api/index.md) for further details.
|
||||
|
|
@ -43,7 +43,7 @@ Importing data is a multi-step process, which is managed via an *import session*
|
|||
|
||||
The import session is managed by the InvenTree server, and all import session data is stored on the server. As the import process can be time-consuming, the user can navigate away from the import page and return later to check on the progress of the import.
|
||||
|
||||
Import sessions can be managed from the [Admin Center](./admin.md#admin-center) page, which lists all available import sessions
|
||||
Import sessions can be managed from the [Admin Center](../settings/admin.md#admin-center) page, which lists all available import sessions
|
||||
|
||||
### Context Sensitive Importing
|
||||
|
||||
|
|
@ -55,9 +55,9 @@ An import session can be initiated from a number of different contexts within th
|
|||
|
||||
### Admin Center
|
||||
|
||||
Staff users can create an import session from within the [Admin Center](./admin.md#admin-center). This is a general-purpose import session, and the user will be required to select the type of data to import.
|
||||
Staff users can create an import session from within the [Admin Center](../settings/admin.md#admin-center). This is a general-purpose import session, and the user will be required to select the type of data to import.
|
||||
|
||||
Users can quickly navigate to the data import managemement page from the [spotlight search](../concepts/user_interface.md#spotlight), by searching for "import" and selecting the "Import data" option.
|
||||
Users can quickly navigate to the data import management page from the [spotlight search](../concepts/user_interface.md#spotlight), by searching for "import" and selecting the "Import data" option.
|
||||
|
||||
### Data Tables
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ The basic outline of this process is:
|
|||
### Create Import Session
|
||||
|
||||
!!! note "Admin Center"
|
||||
Updating existing records can only be performed when creating a new import session from the [Admin Center](./admin.md#admin-center).
|
||||
Updating existing records can only be performed when creating a new import session from the [Admin Center](../settings/admin.md#admin-center).
|
||||
|
||||
Create a new import session, and ensure that the *Update Existing Records* option is selected. This will allow the import session to update existing records in the database.
|
||||
|
||||
|
|
@ -162,7 +162,7 @@ The core software modules are targeting the following versions:
|
|||
| Python | {{ config.extra.min_python_version }} | Minimum required version |
|
||||
| Invoke | {{ config.extra.min_invoke_version }} | Minimum required version |
|
||||
| Django | {{ config.extra.django_version }} | Pinned version |
|
||||
| Node | 20 | Only needed for frontend development |
|
||||
| Node | 24 | Only needed for frontend development |
|
||||
|
||||
Any other software dependencies are handled by the project package config.
|
||||
|
||||
|
|
|
|||
|
|
@ -195,3 +195,24 @@ This means that either:
|
|||
- The docker user does not have write permission to the specified directory
|
||||
|
||||
In either case, ensure that the directory is available *on your local machine* and the user account has the required permissions.
|
||||
|
||||
|
||||
## Error Rendering Component
|
||||
|
||||
Sometimes, following a software update, you may find that certain components of the web interface are not rendering correctly, and presented with a message similar to the screenshot below:
|
||||
|
||||
{{ image("faq/boundary.png", "Error Rendering Component") }}
|
||||
|
||||
This is often due to a caching issue with your web browser. Try performing a hard refresh of the page to clear the cache, this should resolve the issue in most cases.
|
||||
|
||||
If the problem persists, refer to the [troubleshooting guide](./troubleshooting.md) for further assistance.
|
||||
|
||||
## Expression tree is too large
|
||||
|
||||
If you are running a large InvenTree deployment on an SQLite database, you may encounter an error similar to:
|
||||
|
||||
```
|
||||
Expression tree is too large (maximum depth 1000)
|
||||
```
|
||||
|
||||
This is a [known limitation of SQLite](https://www.sqlite.org/limits.html) which can occur when performing complex queries on a large database. Due to [structural limitations](./start/processes.md#sqlite-limitations) of SQLite, it is recommended to use a more robust database backend such as PostgreSQL for larger deployments.
|
||||
|
|
|
|||
|
|
@ -80,7 +80,16 @@ The *Deallocate Stock* button can be used to remove all allocations of untracked
|
|||
|
||||
## Automatic Stock Allocation
|
||||
|
||||
To speed up the allocation process, the *Auto Allocate* button can be used to allocate untracked stock items to the build. Automatic allocation of stock items does not work in every situation, as a number of criteria must be met.
|
||||
To speed up the allocation process, the *Auto Allocate* button can be used to automatically allocate stock items to the build.
|
||||
|
||||
!!! info "Background Task"
|
||||
Auto-allocation runs as a background task. The UI will display a progress indicator while the task is running.
|
||||
|
||||
#### Selecting Lines to Allocate
|
||||
|
||||
By default, auto-allocation processes **all eligible BOM line items** in the build order. To restrict allocation to a subset of lines, select the desired rows in the allocation table before pressing the button — the dialog will indicate how many lines are selected.
|
||||
|
||||
#### Auto Allocation Options
|
||||
|
||||
The *Automatic Allocation* dialog is presented as shown below:
|
||||
|
||||
|
|
@ -90,12 +99,16 @@ The *Automatic Allocation* dialog is presented as shown below:
|
|||
|
||||
Select the master location where stock items are to be allocated from. Leave this input blank to allocate stock items from any available location.
|
||||
|
||||
**Exclude Location**
|
||||
|
||||
Exclude stock from a specific location (and all of its sub-locations). Useful for reserving stock in a particular area.
|
||||
|
||||
**Interchangeable Stock**
|
||||
|
||||
Set this option to *True* to signal that stock items can be used interchangeably. This means that in the case where multiple stock items are available, the auto-allocation routine does not care which stock item it uses.
|
||||
|
||||
!!! warning "Take Care"
|
||||
If the *Interchangeable Stock* option is enabled, and there are multiple stock items available, the results of the automatic allocation algorithm may somewhat unexpected.
|
||||
If the *Interchangeable Stock* option is enabled, and there are multiple stock items available, the results of the automatic allocation algorithm may be somewhat unexpected.
|
||||
|
||||
!!! info "Example"
|
||||
Let's say that we have 5 reels of our *C_100nF_0603* capacitor, each with 4,000 parts available. If we do not mind which of these reels the stock should be taken from, we enable the *Interchangeable Stock* option in the dialog above. In this case, the stock will be allocated from one of these reels, and eventually subtracted from stock when the build is completed.
|
||||
|
|
@ -104,6 +117,32 @@ Set this option to *True* to signal that stock items can be used interchangeably
|
|||
|
||||
Set this option to *True* to allow substitute parts (as specified by the BOM) to be allocated, if the primary parts are not available.
|
||||
|
||||
**Optional Items**
|
||||
|
||||
Set this option to *True* to include optional BOM line items in the auto-allocation. By default, optional items are not automatically allocated.
|
||||
|
||||
**Item Type**
|
||||
|
||||
Controls which category of BOM line items is considered for auto-allocation:
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| Untracked Items | Only untracked (non-serialized) BOM lines are allocated *(default)* |
|
||||
| Tracked Items | Only tracked BOM lines are allocated |
|
||||
| All Items | Both tracked and untracked BOM lines are allocated |
|
||||
|
||||
**Stock Priority**
|
||||
|
||||
Controls the order in which matching stock items are consumed:
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| Oldest stock first (FIFO) | Stock items updated least recently are consumed first *(default)* |
|
||||
| Newest stock first (LIFO) | Stock items updated most recently are consumed first |
|
||||
| Smallest quantity first | Stock items with the lowest available quantity are consumed first |
|
||||
| Largest quantity first | Stock items with the highest available quantity are consumed first |
|
||||
| Soonest expiry date first | Stock items expiring earliest are consumed first; items with no expiry date are used last |
|
||||
|
||||
## Allocating Tracked Stock
|
||||
|
||||
Allocation of tracked stock items is slightly more complex. Instead of being allocated against the *Build Order*, tracked stock items must be allocated against an individual *Build Output*.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ title: Bill of Materials
|
|||
|
||||
## Bill of Materials
|
||||
|
||||
A Bill of Materials (BOM) defines the list of component parts required to make an assembly, [create builds](./build.md) and allocate inventory.
|
||||
A Bill of Materials (BOM) defines the list of component parts required to make an assembly, [create build orders](./build.md) and allocate inventory.
|
||||
|
||||
A part which can be built from other sub components is called an *Assembly*.
|
||||
|
||||
|
|
@ -18,7 +18,8 @@ A BOM for a particular assembly is comprised of a number (zero or more) of BOM "
|
|||
| --- | --- |
|
||||
| Part | A reference to another *Part* object which is required to build this assembly |
|
||||
| Reference | Optional reference field to describe the BOM Line Item, e.g. part designator |
|
||||
| Quantity | The quantity of *Part* required for the assembly |
|
||||
| Raw Amount | The raw quantity of the part required for the assembly, which can be expressed in different units of measure, e.g. `2 cm`, `1/2 inch`, `200 kg`. |
|
||||
| Quantity | The quantity of *Part* required for the assembly - this value is automatically calculated from the "raw amount" field, taking into account the units of measure associated with the underlying part. |
|
||||
| Attrition | Estimated attrition losses for a production run. Expressed as a percentage of the base quantity (e.g. 2%) |
|
||||
| Setup Quantity | An additional quantity of the part which is required to account for fixed setup losses during the production process. This is added to the base quantity of the BOM line item |
|
||||
| Rounding Multiple | A value which indicates that the required quantity should be rounded up to the nearest multiple of this value. |
|
||||
|
|
@ -27,6 +28,18 @@ A BOM for a particular assembly is comprised of a number (zero or more) of BOM "
|
|||
| Optional | A boolean field which indicates if this BOM Line Item is "optional" |
|
||||
| Note | Optional note field for additional information
|
||||
|
||||
### Units of Measure
|
||||
|
||||
The `raw_amount` field allows the user to specify the required quantity of a particular part in different [units of measure](../concepts/units.md). The units of measure are determined by the underlying part definition. For example, if the part is defined with a default unit of measure of "kg", the user can specify the required quantity in "g", "mg", "lb", etc.
|
||||
|
||||
The `raw_amount` field is stored as a string, and the `quantity` field is automatically calculated from the `raw_amount` field, taking into account the units of measure associated with the underlying part. This allows for greater flexibility in specifying the required quantity of a particular part, while still maintaining accurate tracking of inventory and production requirements.
|
||||
|
||||
If the underlying part does not have a defined unit of measure, the `raw_amount` field is not allowed to have any units of measure specified, and the `quantity` field is simply a numeric representation of the `raw_amount` field.
|
||||
|
||||
### Fractional Representation
|
||||
|
||||
The `raw_amount` field also allows for fractional representation of the required quantity. For example, if the required quantity is 0.5 kg, the user can specify this as `500 g`, `0.5 kg`, `1/2 kg`, etc. The `quantity` field will be automatically calculated as 0.5 kg, regardless of the specific representation used in the `raw_amount` field.
|
||||
|
||||
### Consumable BOM Line Items
|
||||
|
||||
If a BOM line item is marked as *consumable*, this means that while the part and quantity information is tracked in the BOM, this line item does not get allocated to a [Build Order](./build.md). This may be useful for certain items that the user does not wish to track through the build process, as they may be low value, in abundant stock, or otherwise complicated to track.
|
||||
|
|
@ -92,13 +105,20 @@ Note that inherited BOM Line Items only flow "downwards" in the variant inherita
|
|||
!!! info "Editing Inherited Items"
|
||||
When editing an inherited BOM Line Item for a template part, the changes are automatically reflected in the BOM of any variant parts.
|
||||
|
||||
## BOM Creation
|
||||
## BOM Editing
|
||||
|
||||
BOMs can be created manually, by adjusting individual line items, or by uploading (importing) an existing BOM file.
|
||||
Bills of Material (BOMs) can be created manually, by adjusting individual line items, or by uploading (importing) an existing BOM file.
|
||||
|
||||
### Editing Mode
|
||||
|
||||
By default, the BOM is displayed in "view" mode. To edit the BOM, click on the {{ icon("edit", color="blue", title="Edit") }} icon at the top of the BOM panel. This will enable editing mode, which allows you to add, adjust or delete BOM line items.
|
||||
|
||||
!!! warning "Permissions"
|
||||
Only users with the appropriate permissions can edit BOMs. If you do not have permission to edit the BOM, the "Edit" icon will not be visible.
|
||||
|
||||
### Importing a BOM
|
||||
|
||||
BOM data can be imported from an existing file (such as CSV or Excel) from the *BOM* panel for a particular part/assembly. This process is a special case of the more general [data import process](../settings/import.md).
|
||||
BOM data can be imported from an existing file (such as CSV or Excel) from the *BOM* panel for a particular part/assembly. This process is a special case of the more general [data import process](../concepts/data_import.md).
|
||||
|
||||
At the top of the *BOM* panel, click on the {{ icon("file-arrow-left", color="green", title="Import BOM Data") }} icon to open the import dialog.
|
||||
|
||||
|
|
@ -162,89 +182,40 @@ If the BOM requires revalidation, the status will be displayed as "Not Validated
|
|||
|
||||
{{ image("build/bom_invalid.png", "BOM Not Validated") }}
|
||||
|
||||
## Required Quantity Calculation
|
||||
## BOM Comparison
|
||||
|
||||
When a new [Build Order](./build.md) is created, the required production quantity of each component part is calculated based on the BOM line items defined for the assembly being built. To calculate the required production quantity of a component part, the following considerations are made:
|
||||
It is possible to compare the BOM of one assembly with another assembly. This comparison can highlight different component parts, quantities and other properties of the BOM line items.
|
||||
|
||||
### Base Quantity
|
||||
To compare the BOM of one assembly with another, navigate to the "Bill of Materials" tab of the part detail page, then click on the {{ icon("git-compare", color="blue", title="Compare BOM") }} icon at the top of the BOM table:
|
||||
|
||||
The base quantity of a BOM line item is defined by the `Quantity` field of the BOM line item. This is the number of parts which are required to build one assembly. This value is multiplied by the number of assemblies which are being built to determine the total quantity of parts required.
|
||||
{{ image("build/bom_compare_icon.png", "BOM Compare") }}
|
||||
|
||||
```
|
||||
Required Quantity = Base Quantity * Number of Assemblies
|
||||
```
|
||||
This will open the BOM comparison view, which allows you to select a secondary assembly to compare with the primary assembly. The BOM line items of the two assemblies will be displayed side by side, with differences highlighted:
|
||||
|
||||
### Attrition
|
||||
{{ image("build/bom_compare.png", "BOM Compare") }}
|
||||
|
||||
The `Attrition` field of a BOM line item is used to account for expected losses during the production process. This is expressed as a percentage of the `Base Quantity` (e.g. 2%).
|
||||
### Display Mode
|
||||
|
||||
If a non-zero attrition percentage is specified, it is applied to the calculated `Required Quantity` value.
|
||||
When comparing BOMs from two different assemblies, the user can select from the following view modes:
|
||||
|
||||
```
|
||||
Required Quantity = Required Quantity * (1 + Attrition Percentage)
|
||||
```
|
||||
| View Mode | Description |
|
||||
| --- | --- |
|
||||
| *Show all parts* | Display all BOM line items from both assemblies. Differences are highlighted. |
|
||||
| *Show different parts* | Display only the BOM line items which are different between the two assemblies. |
|
||||
| *Show common parts* | Display only the BOM line items which are common between the two assemblies. |
|
||||
|
||||
!!! info "Optional"
|
||||
The attrition percentage is optional. If not specified, it defaults to 0%.
|
||||
In each case, any differences between the BOM line items are highlighted in red.
|
||||
|
||||
### Setup Quantity
|
||||
## Replacing Components
|
||||
|
||||
The `Setup Quantity` field of a BOM line item is used to account for fixed losses during the production process. This is an additional quantity of the part which is required to ensure that the production run can be completed successfully. This value is added to the calculated `Required Quantity`.
|
||||
When a component is used in the BOM for multiple assemblies, it can be time consuming to update the BOM for each assembly when a change is required. InvenTree provides a "Replace Component" function which streamlines the process of replacing a component part with another part across multiple BOMs.
|
||||
|
||||
```
|
||||
Required Quantity = Required Quantity + Setup Quantity
|
||||
```
|
||||
To replace a component part within multiple assemblies:
|
||||
|
||||
!!! info "Optional"
|
||||
The setup quantity is optional. If not specified, it defaults to 0.
|
||||
- Navigate to the [Used In](../part/views.md#used-in) tab of the component part detail page
|
||||
- Select the assemblies you wish to update by ticking the checkbox next to each assembly
|
||||
- Click on the {{ icon("replace", color="blue", title="Replace Component") }} icon to open the "Replace Component" dialog
|
||||
|
||||
### Rounding Multiple
|
||||
The following dialog will be displayed, which allows the user to select a new component part to replace the existing component part in the BOM of the selected assemblies:
|
||||
|
||||
The `Rounding Multiple` field of a BOM line item is used to round the calculated `Required Quantity` value to the nearest multiple of the specified value. This is useful for ensuring that the required quantity is a whole number, or to meet specific packaging requirements.
|
||||
|
||||
```
|
||||
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
||||
```
|
||||
|
||||
!!! info "Optional"
|
||||
The rounding multiple is optional. If not specified, no rounding is applied to the calculated production quantity.
|
||||
|
||||
### Example Calculation
|
||||
|
||||
Consider a BOM line item with the following properties:
|
||||
|
||||
- Base Quantity: 3
|
||||
- Attrition: 2% (0.02)
|
||||
- Setup Quantity: 10
|
||||
- Rounding Multiple: 25
|
||||
|
||||
If we are building 100 assemblies, the required quantity would be calculated as follows:
|
||||
|
||||
```
|
||||
Required Quantity = Base Quantity * Number of Assemblies
|
||||
= 3 * 100
|
||||
= 300
|
||||
|
||||
Attrition Value = Required Quantity * Attrition Percentage
|
||||
= 300 * 0.02
|
||||
= 6
|
||||
|
||||
Required Quantity = Required Quantity + Attrition Value
|
||||
= 300 + 6
|
||||
= 306
|
||||
|
||||
Required Quantity = Required Quantity + Setup Quantity
|
||||
= 306 + 10
|
||||
= 316
|
||||
|
||||
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
||||
= ceil(316 / 25) * 25
|
||||
= 13 * 25
|
||||
= 325
|
||||
|
||||
```
|
||||
|
||||
So the final required production quantity of the component part would be `325`.
|
||||
|
||||
!!! info "Calculation"
|
||||
The required quantity calculation is performed automatically when a new [Build Order](./build.md) is created.
|
||||
{{ image("build/replace_component.png", "Replace Component") }}
|
||||
|
|
|
|||
|
|
@ -304,10 +304,11 @@ The following [global settings](../settings/global.md) are available for adjusti
|
|||
| Name | Description | Default | Units |
|
||||
| ---- | ----------- | ------- | ----- |
|
||||
{{ globalsetting("BUILDORDER_REFERENCE_PATTERN") }}
|
||||
{{ globalsetting("BUILDORDER_EXTERNAL_BUILDS") }}
|
||||
{{ globalsetting("BUILDORDER_REQUIRE_RESPONSIBLE") }}
|
||||
{{ globalsetting("BUILDORDER_REQUIRE_ACTIVE_PART") }}
|
||||
{{ globalsetting("BUILDORDER_REQUIRE_LOCKED_PART") }}
|
||||
{{ globalsetting("BUILDORDER_REQUIRE_VALID_BOM") }}
|
||||
{{ globalsetting("BUILDORDER_REQUIRE_CLOSED_CHILDS") }}
|
||||
{{ globalsetting("PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS") }}
|
||||
{{ globalsetting("BUILDORDER_EXTERNAL_BUILDS") }}
|
||||
{{ globalsetting("BUILDORDER_EXTERNAL_REQUIRED") }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
---
|
||||
title: Required Build Quantity
|
||||
---
|
||||
|
||||
## Required Build Quantity
|
||||
|
||||
When a new [Build Order](./build.md) is created, the required production quantity of each component part is calculated based on the BOM line items defined for the assembly being built. To calculate the required production quantity of a component part, the following considerations are made:
|
||||
|
||||
### Base Quantity
|
||||
|
||||
The base quantity of a BOM line item is defined by the `Quantity` field of the BOM line item. This is the number of parts which are required to build one assembly. This value is multiplied by the number of assemblies which are being built to determine the total quantity of parts required.
|
||||
|
||||
```
|
||||
Required Quantity = Base Quantity * Number of Assemblies
|
||||
```
|
||||
|
||||
### Attrition
|
||||
|
||||
The `Attrition` field of a BOM line item is used to account for expected losses during the production process. This is expressed as a percentage of the `Base Quantity` (e.g. 2%).
|
||||
|
||||
If a non-zero attrition percentage is specified, it is applied to the calculated `Required Quantity` value.
|
||||
|
||||
```
|
||||
Required Quantity = Required Quantity * (1 + Attrition Percentage)
|
||||
```
|
||||
|
||||
!!! info "Optional"
|
||||
The attrition percentage is optional. If not specified, it defaults to 0%.
|
||||
|
||||
### Setup Quantity
|
||||
|
||||
The `Setup Quantity` field of a BOM line item is used to account for fixed losses during the production process. This is an additional quantity of the part which is required to ensure that the production run can be completed successfully. This value is added to the calculated `Required Quantity`.
|
||||
|
||||
```
|
||||
Required Quantity = Required Quantity + Setup Quantity
|
||||
```
|
||||
|
||||
!!! info "Optional"
|
||||
The setup quantity is optional. If not specified, it defaults to 0.
|
||||
|
||||
### Rounding Multiple
|
||||
|
||||
The `Rounding Multiple` field of a BOM line item is used to round the calculated `Required Quantity` value to the nearest multiple of the specified value. This is useful for ensuring that the required quantity is a whole number, or to meet specific packaging requirements.
|
||||
|
||||
```
|
||||
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
||||
```
|
||||
|
||||
!!! info "Optional"
|
||||
The rounding multiple is optional. If not specified, no rounding is applied to the calculated production quantity.
|
||||
|
||||
### Example Calculation
|
||||
|
||||
Consider a BOM line item with the following properties:
|
||||
|
||||
- Base Quantity: 3
|
||||
- Attrition: 2% (0.02)
|
||||
- Setup Quantity: 10
|
||||
- Rounding Multiple: 25
|
||||
|
||||
If we are building 100 assemblies, the required quantity would be calculated as follows:
|
||||
|
||||
```
|
||||
Required Quantity = Base Quantity * Number of Assemblies
|
||||
= 3 * 100
|
||||
= 300
|
||||
|
||||
Attrition Value = Required Quantity * Attrition Percentage
|
||||
= 300 * 0.02
|
||||
= 6
|
||||
|
||||
Required Quantity = Required Quantity + Attrition Value
|
||||
= 300 + 6
|
||||
= 306
|
||||
|
||||
Required Quantity = Required Quantity + Setup Quantity
|
||||
= 306 + 10
|
||||
= 316
|
||||
|
||||
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
||||
= ceil(316 / 25) * 25
|
||||
= 13 * 25
|
||||
= 325
|
||||
|
||||
```
|
||||
|
||||
So the final required production quantity of the component part would be `325`.
|
||||
|
||||
!!! info "Calculation"
|
||||
The required quantity calculation is performed automatically when a new [Build Order](./build.md) is created.
|
||||
|
|
@ -48,7 +48,7 @@ If the *Add Supplier Data* option is checked, then supplier part and manufacture
|
|||
|
||||
Parts can be imported from an external file, by selecting the *Import from File* option.
|
||||
|
||||
This action opens the [data import wizard](../settings/import.md), which steps the user through the process of importing parts from the selected file.
|
||||
This action opens the [data import wizard](../concepts/data_import.md), which steps the user through the process of importing parts from the selected file.
|
||||
|
||||
## Import from Supplier
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@ title: Parts
|
|||
|
||||
The *Part* is the core element of the InvenTree ecosystem. A Part object is the archetype of any stock item in your inventory. Parts are arranged in hierarchical categories which are used to organize and filter parts by function.
|
||||
|
||||
## Part Stock
|
||||
|
||||
Each part can have multiple [stock items](../stock/index.md) associated with it, which represent the physical quantity of that part in various locations. The total stock level for a given part is the sum of all stock items associated with that part.
|
||||
|
||||
### Minimum Stock
|
||||
|
||||
A part may have a specified "minimum stock" level. This is a user-defined value which indicates the minimum quantity of that part which should be kept in stock at all times. If the total stock level for a given part falls below the minimum stock level, the part is flagged as "low stock" and can be easily identified in the interface.
|
||||
|
||||
### Maximum Stock
|
||||
|
||||
A part may also have a specified "maximum stock" level. This is a user-defined value which indicates the maximum quantity of that part which should be kept in stock at all times. If the total stock level for a given part exceeds the maximum stock level, the part is flagged as "overstocked" and can be easily identified in the interface.
|
||||
|
||||
## Part Category
|
||||
|
||||
Part categories are very flexible and can be easily arranged to match a particular user requirement. Each part category displays a list of all parts *under* that given category. This means that any part belonging to a particular category, or belonging to a sub-category, will be displayed.
|
||||
|
|
@ -27,7 +39,7 @@ Clicking on the part name links to the [*Part Detail*](./views.md) view.
|
|||
|
||||
## Part Attributes
|
||||
|
||||
Each *Part* defined in the database provides a number of different attributes which determine how that part can be used. Configuring these attributes for a given part will impact the available functions that can be perform on (or using) that part).
|
||||
Each *Part* defined in the database provides a number of different attributes which determine how that part can be used. Configuring these attributes for a given part will impact the available functions that can be perform on (or using) that part.
|
||||
|
||||
### Virtual
|
||||
|
||||
|
|
@ -75,6 +87,7 @@ A [Purchase Order](../purchasing/purchase_order.md) allows parts to be ordered f
|
|||
|
||||
If a part is designated as *Salable* it can be sold to external customers. Setting this flag allows parts to be added to sales orders.
|
||||
|
||||
|
||||
## Locked Parts
|
||||
|
||||
Parts can be locked to prevent them from being modified. This is useful for parts which are in production and should not be changed. The following restrictions apply to parts which are locked:
|
||||
|
|
@ -83,6 +96,8 @@ Parts can be locked to prevent them from being modified. This is useful for part
|
|||
- BOM items cannot be created, edited, or deleted when they are part of a locked assembly
|
||||
- Parameters linked to a locked part cannot be created, edited or deleted
|
||||
|
||||
The part locking functionality can be enabled or disabled globally via the [Part Locking](../settings/global.md#parts) system setting (`PART_ENABLE_LOCKING`). When disabled, the locked state of a part is ignored and all operations are permitted.
|
||||
|
||||
## Active Parts
|
||||
|
||||
By default, all parts are *Active*. Marking a part as inactive means it is not available for many actions, but the part remains in the database. If a part becomes obsolete, it is recommended that it is marked as inactive, rather than deleting it from the database.
|
||||
|
|
@ -145,4 +160,4 @@ The [InvenTree mobile app](../app/part.md#part-image-view) allows part images to
|
|||
|
||||
## Part Import
|
||||
|
||||
*Parts* can be imported by staff-members on the part-list-view (this feature must be enabled in the part-settings), in the part-settings or on the [admin-page for parts](../settings/import.md) (only accessible if you are also an admin). The first two options provide a multi-stage wizard that enables mapping fields from various spreadsheet or table-data formats while the latter requires a well-formatted file but is much more performant.
|
||||
*Part* data can be imported using the [data import wizard](../concepts/data_import.md). This allows part data to be imported from an external file, which can be useful for bulk importing of part data.
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ If you want to make your life easier, try to follow these guidelines; break wher
|
|||
from plugin import InvenTreePlugin, registry
|
||||
from plugin.mixins import APICallMixin, SettingsMixin, ScheduleMixin, BarcodeMixin
|
||||
```
|
||||
- Feliver as a package (see [below](#packaging))
|
||||
- Deliver as a package (see [below](#packaging))
|
||||
- If you need to use a private infrastructure, use the 'Releases' functions in GitHub or Gitlab. Point to the 'latest' release endpoint when installing to make sure the update function works
|
||||
- Tag your GitHub repo with `inventree` and `inventreeplugins` to make discovery easier. A discovery mechanism using these tags is on the roadmap.
|
||||
- Use GitHub actions to test your plugin regularly (you can [schedule actions](https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule)) against the 'latest' [docker-build](https://hub.docker.com/r/inventree/inventree) of InvenTree
|
||||
|
|
|
|||
|
|
@ -8,3 +8,27 @@ If this mixin is added to a plugin the directory the plugin class is defined in
|
|||
|
||||
!!! warning "Danger Zone"
|
||||
Only use this mixin if you have an understanding of Django's [app system]({% include "django.html" %}/ref/applications). Plugins with this mixin are deeply integrated into InvenTree and can cause difficult to reproduce or long-running errors. Use the built-in testing functions of Django to make sure your code does not cause unwanted behaviour in InvenTree before releasing.
|
||||
|
||||
## Custom Models
|
||||
|
||||
This mixin allows you to define custom database models within your plugin. These models will be automatically registered with the InvenTree server, and will be available for use within your plugin code.
|
||||
|
||||
### Model Permissions
|
||||
|
||||
Some database operations within the InvenTree ecosystem may require custom permissions checks - to determine which actions a user can perform against a given model. If your plugin defines custom models, you may need to implement a custom permission check method on your model class.
|
||||
|
||||
Each model class can implement a `check_user_permission` classmethod, which will be called by the InvenTree permission system when checking permissions for that model. This method should return `True` if the user has the required permissions, and `False` otherwise.
|
||||
|
||||
|
||||
```python
|
||||
class MyCustomModel(models.Model):
|
||||
# model fields here
|
||||
|
||||
@classmethod
|
||||
def check_user_permission(cls, user: User, permission: str) -> bool:
|
||||
# custom permission logic here
|
||||
return True # or False
|
||||
```
|
||||
|
||||
!!! warning "Default Permissions"
|
||||
By default, if the `check_user_permission` method is not implemented, the InvenTree permission system will return `False` for all permission checks against that model. This is to ensure that no permissions are granted by default, and that the plugin developer must explicitly define the required permissions for their custom models.
|
||||
|
|
|
|||
|
|
@ -183,6 +183,20 @@ The `get_ui_template_previews` feature type can be used to provide custom templa
|
|||
summary: False
|
||||
members: []
|
||||
|
||||
### Primary Actions
|
||||
|
||||
The `get_ui_primary_actions` method can be used to provide custom primary action, which are rendered in the header of the page, next to the title/name and any status indicators. These primary actions are typically used to provide quick access to common actions related to the current page.
|
||||
|
||||
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_primary_actions
|
||||
options:
|
||||
show_bases: False
|
||||
show_root_heading: False
|
||||
show_root_toc_entry: False
|
||||
extra:
|
||||
show_source: True
|
||||
summary: False
|
||||
members: []
|
||||
|
||||
## Plugin Context
|
||||
|
||||
When rendering certain content in the user interface, the rendering functions are passed a `context` object which contains information about the current page being rendered. The type of the `context` object is defined in the `PluginContext` file:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: Resources InvenTree receives
|
||||
title: Sponsored Resources
|
||||
---
|
||||
|
||||
The InvenTree project is grateful to receive resources from various vendors free of charge.
|
||||
|
|
@ -18,5 +18,6 @@ Individuals and companies can also support via [GitHub sponsors](https://github.
|
|||
## Past Supporters
|
||||
|
||||
Non comprehensive list of past supporters. The project stops consuming resources for various reasons, this does not mean they are not good resources.
|
||||
|
||||
- [Coveralls](https://coveralls.io/) - Code coverage as a service
|
||||
- [Deepsource](https://deepsource.io/) - Code quality and security analysis
|
||||
|
|
@ -110,6 +110,79 @@ After shipments were created, user can either:
|
|||
|
||||
During the allocation process, user is required to select the desired shipment that will contain the stock items.
|
||||
|
||||
### Auto Allocate Stock
|
||||
|
||||
To speed up the allocation process, use the *Auto Allocate Stock* button ({{ icon("wand") }}) available in the *Line Items* tab. This automatically finds available stock and creates the required allocations with minimal user interaction.
|
||||
|
||||
!!! info "Background Task"
|
||||
Auto-allocation runs as a background task. The UI will display a progress indicator while the task is running.
|
||||
|
||||
#### Selecting Lines to Allocate
|
||||
|
||||
By default, auto-allocation processes **all unallocated line items** on the order. To restrict allocation to a subset of lines, select the desired rows in the *Line Items* table before pressing the button — the dialog will indicate how many lines are selected.
|
||||
|
||||
#### Auto Allocation Options
|
||||
|
||||
The auto-allocation dialog provides the following options:
|
||||
|
||||
**Source Location**
|
||||
|
||||
Restrict stock to a specific location (and all of its sub-locations). Leave blank to consider stock from any location.
|
||||
|
||||
**Exclude Location**
|
||||
|
||||
Exclude stock from a specific location (and all of its sub-locations). Useful for reserving stock in a particular area.
|
||||
|
||||
**Shipment**
|
||||
|
||||
Optionally assign all new allocations to a specific pending shipment. Only shipments that have not yet been completed are shown.
|
||||
|
||||
**Interchangeable Stock**
|
||||
|
||||
When enabled (default), stock may be drawn from multiple stock items or locations to fulfil a single line item. When disabled, a line item is only allocated if a single stock item can cover the entire remaining quantity.
|
||||
|
||||
!!! warning "Take Care"
|
||||
Enabling *Interchangeable Stock* means the auto-allocation routine will combine stock from different batches or locations. Review the resulting allocations if traceability is important.
|
||||
|
||||
**Stock Priority**
|
||||
|
||||
Controls the order in which matching stock items are consumed:
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| Oldest stock first (FIFO) | Stock items updated least recently are consumed first *(default)* |
|
||||
| Newest stock first (LIFO) | Stock items updated most recently are consumed first |
|
||||
| Smallest quantity first | Stock items with the lowest available quantity are consumed first |
|
||||
| Largest quantity first | Stock items with the highest available quantity are consumed first |
|
||||
| Soonest expiry date first | Stock items expiring earliest are consumed first; items with no expiry date are used last |
|
||||
|
||||
**Serialized Stock**
|
||||
|
||||
Controls whether serialized stock items are included in the auto-allocation:
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| Allow any stock | Both serialized and unserialized stock items are considered *(default)* |
|
||||
| Serialized stock only | Only stock items that carry a serial number are allocated |
|
||||
| Unserialized stock only | Only stock items without a serial number are allocated |
|
||||
|
||||
#### Allocation Behaviour
|
||||
|
||||
The auto-allocation routine performs the following steps for each eligible line item:
|
||||
|
||||
1. Skips line items for *virtual* parts.
|
||||
2. Skips line items that are already fully allocated.
|
||||
3. Queries available stock for the line's part, applying any location and serialized-stock filters.
|
||||
4. Sorts the candidates according to the chosen *Stock Priority*.
|
||||
5. Greedily allocates from each stock item in turn until the remaining quantity for the line is satisfied.
|
||||
|
||||
#### Removing Allocations
|
||||
|
||||
Individual or multiple allocations can be removed from the *Allocated Stock* tab. Select the allocations to remove and use the *Delete* action.
|
||||
|
||||
!!! warning "Shipped Allocations Protected"
|
||||
Allocations that belong to a completed (shipped) shipment cannot be deleted.
|
||||
|
||||
### Check Shipment
|
||||
|
||||
Shipments can be marked as "checked" to indicate that the items in the shipment has been verified. To mark a shipment as "checked", open the shipment actions menu, and select the "Check" action:
|
||||
|
|
@ -234,3 +307,4 @@ The following [global settings](../settings/global.md) are available for sales o
|
|||
{{ globalsetting("SALESORDER_EDIT_COMPLETED_ORDERS") }}
|
||||
{{ globalsetting("SALESORDER_SHIP_COMPLETE") }}
|
||||
{{ globalsetting("SALESORDER_SHIPMENT_REQUIRES_CHECK") }}
|
||||
{{ globalsetting("SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS")}}
|
||||
|
|
|
|||
|
|
@ -6,24 +6,24 @@ The InvenTree project is committed to providing a secure and safe environment fo
|
|||
|
||||
To that end, we have implemented a number of security measures over the years, which we will outline in this document.
|
||||
|
||||
## Organisational measures
|
||||
## Organizational Measures
|
||||
|
||||
The InvenTree project is managed by a small team of developers, who are responsible for the ongoing development and maintenance of the software. Two geographically distributed users have administrative access to the InvenTree codebase. Merges are only done by one of these two users, the maintainer Oliver.
|
||||
Read the Project [Governance](./project/governance.md) document for more information.
|
||||
|
||||
InvenTree is open-source, and we welcome contributions from the community. However, all contributions are reviewed and scrutinised before being merged into the codebase.
|
||||
InvenTree is open-source, and we welcome contributions from the community. However, all contributions are reviewed and scrutinized before being merged into the codebase.
|
||||
|
||||
### Security Policy
|
||||
|
||||
The official [Security Policy]({{ sourcefile("SECURITY.md") }}) is available in the code repository.
|
||||
We provide this document in our main repo to increase discoverabiltity to ensure that all security issues are handled in a timely manner.
|
||||
We provide this document in our main repo to increase discoverability to ensure that all security issues are handled in a timely manner.
|
||||
|
||||
### Past Reports
|
||||
If we become aware of a security issue, we will take immediate action to address the issue, and will provide a public disclosure of the issue once it has been resolved. We support assigning CVEs to security issues where appropriate. Our [past security advisories can be found here](https://github.com/inventree/InvenTree/security/advisories).
|
||||
|
||||
## Technical measures
|
||||
## Technical Measures
|
||||
|
||||
### Code hosting
|
||||
### Code Hosting
|
||||
|
||||
The InvenTree project is hosted on GitHub, and we rely on the security measures provided by GitHub to help protect the integrity of the codebase.
|
||||
|
||||
|
|
@ -35,24 +35,24 @@ Among those are:
|
|||
- (Optional but encouraged) Two-factor authentication for user accounts
|
||||
- (Optional but encouraged) Signed commits / actions
|
||||
|
||||
### Code style
|
||||
### Code Style
|
||||
|
||||
We enforce style and security checks in our CI/CD pipeline, and we have several automated tests to ensure that the codebase is secure and functional.
|
||||
Checks are run on every pull request, and we require that all checks pass before a pull request can be merged.
|
||||
|
||||
### Current versions
|
||||
### Current Versions
|
||||
|
||||
InvenTree is built using the Django framework, which has a strong focus on security. We follow best practices for Django development, and we are committed to keeping the codebase up-to-date with the latest security patches and within supported versions.
|
||||
|
||||
### Test coverage
|
||||
### Test Coverage
|
||||
|
||||
We run coverage tests on our codebase to ensure that we have a high level of test coverage above 90%. This is public and can be found [here](https://app.codecov.io/gh/inventree/InvenTree).
|
||||
|
||||
### Pinning dependencies
|
||||
### Pinning Dependencies
|
||||
|
||||
We are pinning dependencies to specific versions - aiming for complete reproducibility of builds - wherever possible. Combined with continuous OSV checks, we are able to react quickly to security issues in our dependencies.
|
||||
|
||||
## Best practices
|
||||
## Best Practices
|
||||
|
||||
We follow most of GitHubs community best practices, check our compliance [here](https://github.com/inventree/InvenTree/community).
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@ A potentially critical mismatch between the backup environment and the current r
|
|||
|
||||
While using [invoke](../start/invoke.md), this can be overridden with the `--restore-allow-newer-version` flag.
|
||||
|
||||
#### INVE-E17
|
||||
**Error rendering component**
|
||||
|
||||
An error occurred while rendering a component in the frontend. Typically this is caused by a browser caching issue, and can be resolved by clearing the browser cache and refreshing the page. If the issue persists, check the browser console for more information about the error.
|
||||
|
||||
### INVE-W (InvenTree Warning)
|
||||
Warnings - These are non-critical errors which should be addressed when possible.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
title: Exporting Data
|
||||
---
|
||||
|
||||
## Exporting Data
|
||||
|
||||
InvenTree provides data export functionality for a variety of data types. Most data tables provide an "Download" button, which allows the user to export the data in a variety of formats.
|
||||
|
||||
In the top right corner of the table, click the "Download" button to export the data in the table.
|
||||
|
||||
{{ image("admin/export.png", "Download") }}
|
||||
|
||||
This will present a dialog box with the available export options:
|
||||
|
||||
{{ image("admin/export_options.png", "Export Dialog") }}
|
||||
|
||||
## Plugin Support
|
||||
|
||||
InvenTree plugins can also provide custom export functionality for specific data types. If a plugin provides export functionality, it will be listed in the export options.
|
||||
|
||||
Refer to the [export plugin mixin documentation](../plugins/mixins/export.md) for more information on how to create export plugins.
|
||||
|
||||
## API Export
|
||||
|
||||
Data can also be exported via the InvenTree REST API, by appending the appropriate format suffix (and other export options) to the API endpoint URL.
|
||||
|
|
@ -32,11 +32,15 @@ Configuration of basic server settings:
|
|||
{{ globalsetting("INVENTREE_RESTRICT_ABOUT") }}
|
||||
{{ globalsetting("DISPLAY_FULL_NAMES") }}
|
||||
{{ globalsetting("DISPLAY_PROFILE_INFO") }}
|
||||
{{ globalsetting("INVENTREE_UPDATE_CHECK_INTERVAL") }}
|
||||
{{ globalsetting("INVENTREE_DOWNLOAD_FROM_URL") }}
|
||||
{{ globalsetting("INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE") }}
|
||||
{{ globalsetting("INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT") }}
|
||||
{{ globalsetting("WEEK_STARTS_ON") }}
|
||||
{{ globalsetting("INVENTREE_UPLOAD_MAX_SIZE") }}
|
||||
{{ globalsetting("INVENTREE_STRICT_URLS") }}
|
||||
|
||||
Configuration of various scheduled tasks:
|
||||
|
||||
| Name | Description | Default | Units |
|
||||
| ---- | ----------- | ------- | ----- |
|
||||
{{ globalsetting("INVENTREE_UPDATE_CHECK_INTERVAL") }}
|
||||
{{ globalsetting("INVENTREE_BACKUP_ENABLE") }}
|
||||
{{ globalsetting("INVENTREE_BACKUP_DAYS") }}
|
||||
{{ globalsetting("INVENTREE_DELETE_TASKS_DAYS") }}
|
||||
|
|
@ -158,6 +162,7 @@ Configuration of label printing:
|
|||
{{ globalsetting("PART_ALLOW_DUPLICATE_IPN") }}
|
||||
{{ globalsetting("PART_ALLOW_EDIT_IPN") }}
|
||||
{{ globalsetting("PART_ALLOW_DELETE_FROM_ASSEMBLY") }}
|
||||
{{ globalsetting("PART_ENABLE_LOCKING") }}
|
||||
{{ globalsetting("PART_ENABLE_REVISION") }}
|
||||
{{ globalsetting("PART_REVISION_ASSEMBLY_ONLY") }}
|
||||
{{ globalsetting("PART_NAME_FORMAT") }}
|
||||
|
|
@ -206,6 +211,8 @@ Configuration of stock item options
|
|||
| Name | Description | Default | Units |
|
||||
| ---- | ----------- | ------- | ----- |
|
||||
{{ globalsetting("SERIAL_NUMBER_GLOBALLY_UNIQUE") }}
|
||||
{{ globalsetting("STOCK_ALLOW_EDIT_SERIAL") }}
|
||||
{{ globalsetting("STOCK_ALLOW_DELETE_SERIALIZED") }}
|
||||
{{ globalsetting("STOCK_DELETE_DEPLETED_DEFAULT") }}
|
||||
{{ globalsetting("STOCK_BATCH_CODE_TEMPLATE") }}
|
||||
{{ globalsetting("STOCK_ENABLE_EXPIRY") }}
|
||||
|
|
@ -235,6 +242,10 @@ Refer to the [sales order settings](../sales/sales_order.md#sales-order-settings
|
|||
|
||||
Refer to the [return order settings](../sales/return_order.md#return-order-settings).
|
||||
|
||||
### Transfer Orders
|
||||
|
||||
Refer to the [transfer order settings](../stock/transfer_order.md#transfer-order-settings).
|
||||
|
||||
### Plugin Settings
|
||||
|
||||
| Name | Description | Default | Units |
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ InvenTree functionality is split into a number of distinct roles. A group will h
|
|||
| Role | Description |
|
||||
| ---- | ----------- |
|
||||
| **Admin** | The *admin* role is related to assigning user permissions. |
|
||||
| **Build** | The *build* role is related to accessing Build Order and Bill of Materials data |
|
||||
| **BOM** | The *bom* role is related to accessing Bill of Materials data |
|
||||
| **Build** | The *build* role is related to accessing manufacturing / Build Order |
|
||||
| **Part** | The *part* role is related to accessing Part data |
|
||||
| **Part Category** | The *part category* role is related to accessing Part Category data |
|
||||
| **Purchase Order** | The *purchase* role is related to accessing Purchase Order data |
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ The *Display Settings* screen shows general display configuration options:
|
|||
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
|
||||
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
|
||||
{{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}}
|
||||
{{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}}
|
||||
{{ usersetting("ENABLE_LAST_BREADCRUMB") }}
|
||||
{{ usersetting("SHOW_FULL_LOCATION_IN_TABLES") }}
|
||||
{{ usersetting("DISPLAY_ITEMS_FINAL_LEVEL") }}
|
||||
|
||||
### Search Settings
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@ title: InvenTree Installer
|
|||
---
|
||||
|
||||
## Installer
|
||||
|
||||
The InvenTree installer automates the installation procedure for a production InvenTree server.
|
||||
|
||||
Supported OSs are Debian 10, 11, 12 and Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS.
|
||||
The installer supports the following Linux distributions:
|
||||
|
||||
- Debian 11, 12
|
||||
- Ubuntu 22.04 LTS, 24.04 LTS
|
||||
|
||||
Support for other OS versions is not currently planned. If you are using a different distribution, you can still follow the [docker](./docker.md) or [bare metal](./install.md) installation instructions.
|
||||
|
||||
### Quick Script
|
||||
|
||||
|
|
|
|||
|
|
@ -142,6 +142,8 @@ Note that any serial number adjustments are subject to the same validation rules
|
|||
|
||||
{{ image("stock/serial_edit_error.png", title="Error while editing a serial number") }}
|
||||
|
||||
!!! info "Disable Serial Number Editing"
|
||||
If you wish to prevent users from editing serial numbers, this can be achieved by disabling the `Allow Edit Serial Number` setting in the system settings view.
|
||||
|
||||
#### Plugin Support
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
|
||||
---
|
||||
title: Transfer Orders
|
||||
---
|
||||
|
||||
## Transfer Orders
|
||||
|
||||
Transfer orders provide a method for requesting stock to be moved from one location to another. It does not replace the existing on-demand stock transaction options, but lets you "document" many transactions from a single view.
|
||||
|
||||
### View Transfer Orders
|
||||
|
||||
To navigate to the Transfer Order display, select *Stock* from the main navigation menu, and *Transfer Orders* from the sidebar:
|
||||
|
||||
{{ image("stock/transfer_order_display.png", "Transfer Order display") }}
|
||||
|
||||
The following view modes are available:
|
||||
|
||||
#### Table View
|
||||
|
||||
*Table View* provides a list of Transfer Orders, which can be filtered to display a subset of orders according to user supplied parameters.
|
||||
|
||||
{{ image("stock/transfer_order_list.png", "Transfer Order list") }}
|
||||
|
||||
#### Calendar View
|
||||
|
||||
*Calendar View* shows a calendar display with outstanding transfer orders.
|
||||
|
||||
{{ image("stock/transfer_order_calendar.png", "Transfer Order calendar") }}
|
||||
|
||||
### Transfer Order Status Codes
|
||||
|
||||
Each Transfer Order has a specific status code, which represents the state of the order:
|
||||
|
||||
| Status | Description |
|
||||
| --- | --- |
|
||||
| Pending | The transfer order has been created, but has not been finalized or submitted |
|
||||
| Issued | The transfer order has been issued, and is in progress |
|
||||
| On Hold | The transfer order has been placed on hold, but is still active |
|
||||
| Complete | The transfer order is fully completed, and is now closed |
|
||||
| Cancelled | The transfer order was cancelled, and is now closed |
|
||||
|
||||
**Source Code**
|
||||
|
||||
Refer to the source code for the Transfer Order status codes:
|
||||
|
||||
::: order.status_codes.TransferOrderStatus
|
||||
options:
|
||||
show_bases: False
|
||||
show_root_heading: False
|
||||
show_root_toc_entry: False
|
||||
show_source: True
|
||||
members: []
|
||||
|
||||
Transfer Order Status supports [custom states](../concepts/custom_states.md).
|
||||
|
||||
### Transfer Order Parameters
|
||||
|
||||
The following parameters are available for each Transfer Order, and can be edited by the user:
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| Reference | Transfer Order reference e.g. '001' |
|
||||
| Description | Description of the Transfer Order |
|
||||
| Project Code | Project Code of the Transfer Order |
|
||||
| Source Location | Stock location to source stock items from (blank = all locations) |
|
||||
| Destination Location | Stock location where the stock will be transferred |
|
||||
| Consume Stock | Rather than transfer the stock to the destination, "consume" it by removing the specified quantity from the allocated stock item
|
||||
| Start Date | The scheduled start date for the transfer |
|
||||
| Target Date | Target date for transfer completion |
|
||||
| External Link | Link to external webpage |
|
||||
| Responsible | User (or group of users) who is responsible for the transfer |
|
||||
| Notes | Transfer notes, supports markdown |
|
||||
|
||||
## Create a Transfer Order
|
||||
|
||||
Once the transfer order page is loaded, click on <span class="badge inventree add">{{ icon("plus-circle") }} New Transfer Order</span> which opens the "Create Transfer Order" form.
|
||||
|
||||
Fill out the rest of the form with the transfer order information then click on <span class="badge inventree confirm">Submit</span> to create the order.
|
||||
|
||||
### Transfer Order Reference
|
||||
|
||||
Each Transfer Order is uniquely identified by its *Reference* field. Read more about [reference fields](../settings/reference.md).
|
||||
|
||||
### Add Line Items
|
||||
|
||||
On the transfer order detail page, user can link parts to the transfer order selecting the <span class="badge inventree nav side">{{ icon("list") }}</span> Line Items</span> tab then clicking on the <span class="badge inventree add">{{ icon("plus-circle") }} Add Line Item</span> button.
|
||||
|
||||
Once the "Add Line Item" form opens, select a part in the list.
|
||||
|
||||
!!! warning
|
||||
Only parts that have the "Virtual" attribute disabled will be shown and can be selected.
|
||||
|
||||
Fill out the rest of the form then click on <span class="badge inventree confirm">Submit</span>
|
||||
|
||||
### Allocate Stock Items
|
||||
|
||||
After line items were created, user can either:
|
||||
|
||||
* Allocate stock items for that part to the transfer order (click on {{ icon("arrow-right") }} button)
|
||||
* Create a build order for that part to cover the quantity of the transfer order (click on {{ icon("tools") }} button)
|
||||
|
||||
### Complete Order
|
||||
|
||||
Once all items in the transfer order have been allocated, click on <span class="badge inventree add">{{ icon("circle-check", color="green") }} Complete Order</span> to mark the transfer order as complete. Confirm then click on <span class="badge inventree confirm">Submit</span> to complete the order.
|
||||
|
||||
### Transferred Stock
|
||||
|
||||
After completing the transfer order, a <span class="badge inventree nav side">{{ icon("list") }}</span> Transferred Stock</span> tab will appear showing which stock was affected.
|
||||
|
||||
!!! warning
|
||||
Similar to received stock on purchase orders, this tab will only be accurate while the affected stock items still exist. Furthermore, if the stock item is depleted while using the "consume" parameter, it will not appear here unless "delete on deplete" is turned off for this stock item
|
||||
|
||||
### Cancel Order
|
||||
|
||||
To cancel the order, click on the {{ icon("tools") }} menu button next to the <span class="badge inventree add">{{ icon("circle-check", color="green") }} Complete Order</span> button, then click on the "{{ icon("tools") }} Cancel Order" menu option. Confirm then click on the <span class="badge inventree confirm">Submit</span> to cancel the order.
|
||||
|
||||
## Order Scheduling
|
||||
|
||||
Transfer orders can be scheduled for a future date, to allow for order scheduling.
|
||||
|
||||
### Start Date
|
||||
|
||||
The *Start Date* of the transfer order is the date on which the order is scheduled to be issued, allowing work to begin on the order.
|
||||
|
||||
### Target Date
|
||||
|
||||
The *Target Date* of the transfer order is the date on which the order is scheduled to be completed.
|
||||
|
||||
### Overdue Orders
|
||||
|
||||
If the *Target Date* of the transfer order has passed, the order will be marked as *overdue*.
|
||||
|
||||
## Calendar view
|
||||
|
||||
Using the button to the top right of the list of Transfer Orders, the view can be switched to a calendar view using the button {{ icon("calendar") }}. This view shows orders with a defined target date only.
|
||||
|
||||
This view can be accessed externally as an ICS calendar using a URL like the following:
|
||||
`http://inventree.example.org/api/order/calendar/transfer-order/calendar.ics`
|
||||
|
||||
By default, completed orders are not exported. These can be included by appending `?include_completed=True` to the URL.
|
||||
|
||||
## Transfer Order Settings
|
||||
|
||||
The following [global settings](../settings/global.md) are available for transfer orders:
|
||||
|
||||
| Name | Description | Default | Units |
|
||||
| ---- | ----------- | ------- | ----- |
|
||||
{{ globalsetting("TRANSFERORDER_ENABLED") }}
|
||||
{{ globalsetting("TRANSFERORDER_REFERENCE_PATTERN") }}
|
||||
{{ globalsetting("TRANSFERORDER_REQUIRE_RESPONSIBLE") }}
|
||||
|
|
@ -87,7 +87,7 @@ nav:
|
|||
- Project Details:
|
||||
- Governance: project/governance.md
|
||||
- Project Security: security.md
|
||||
- Resources: project/resources.md
|
||||
- Sponsored Resources: project/sponsored_resources.md
|
||||
- Privacy: privacy.md
|
||||
- Concepts:
|
||||
- Terminology: concepts/terminology.md
|
||||
|
|
@ -96,6 +96,8 @@ nav:
|
|||
- Physical Units: concepts/units.md
|
||||
- Companies: concepts/company.md
|
||||
- Custom States: concepts/custom_states.md
|
||||
- Data Export: concepts/data_export.md
|
||||
- Data Import: concepts/data_import.md
|
||||
- Pricing: concepts/pricing.md
|
||||
- Project Codes: concepts/project_codes.md
|
||||
- Attachments: concepts/attachments.md
|
||||
|
|
@ -145,11 +147,13 @@ nav:
|
|||
- Stock Expiry: stock/expiry.md
|
||||
- Stock Ownership: stock/owner.md
|
||||
- Test Results: stock/test.md
|
||||
- Transfer Orders: stock/transfer_order.md
|
||||
- Manufacturing:
|
||||
- Manufacturing: manufacturing/index.md
|
||||
- Bill of Materials: manufacturing/bom.md
|
||||
- Build Orders: manufacturing/build.md
|
||||
- Build Outputs: manufacturing/output.md
|
||||
- Required Quantity: manufacturing/required.md
|
||||
- Allocating Stock: manufacturing/allocate.md
|
||||
- External Manufacturing: manufacturing/external.md
|
||||
- Example Build Order: manufacturing/example.md
|
||||
|
|
@ -184,8 +188,6 @@ nav:
|
|||
- Multi Factor Authentication: settings/MFA.md
|
||||
- Email Settings: settings/email.md
|
||||
- Experimental Features: settings/experimental.md
|
||||
- Export Data: settings/export.md
|
||||
- Import Data: settings/import.md
|
||||
- Operations:
|
||||
- Background Tasks: settings/tasks.md
|
||||
- Error Logs: settings/logs.md
|
||||
|
|
@ -263,6 +265,7 @@ nav:
|
|||
- Stock: app/stock.md
|
||||
- Purchase Orders: app/po.md
|
||||
- Sales Orders: app/so.md
|
||||
- Build Orders: app/build.md
|
||||
- Settings: app/settings.md
|
||||
- Privacy: app/privacy.md
|
||||
- Translation: app/translation.md
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
mkdocs==1.6.1
|
||||
mkdocs-macros-plugin>=0.7,<2.0
|
||||
mkdocs-material>=9.0,<10.0
|
||||
mkdocs-git-revision-date-localized-plugin>=1.1,<2.0
|
||||
mkdocs-git-revision-date-localized-plugin>=1.5.2,<2.0
|
||||
mkdocs-redirects
|
||||
mkdocs-simple-hooks>=0.1,<1.0
|
||||
mkdocs-include-markdown-plugin
|
||||
neoteroi-mkdocs
|
||||
mkdocstrings[python]>=0.25.0,<=1.0.3
|
||||
mkdocstrings[python]<=1.0.4,>=1.0.4
|
||||
mkdocs-mermaid2-plugin
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ bracex==2.6 \
|
|||
--hash=sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952 \
|
||||
--hash=sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7
|
||||
# via wcmatch
|
||||
certifi==2026.2.25 \
|
||||
--hash=sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa \
|
||||
--hash=sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7
|
||||
certifi==2026.4.22 \
|
||||
--hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \
|
||||
--hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# httpcore
|
||||
|
|
@ -169,9 +169,9 @@ charset-normalizer==3.4.7 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
click==8.3.2 \
|
||||
--hash=sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5 \
|
||||
--hash=sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d
|
||||
click==8.3.3 \
|
||||
--hash=sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2 \
|
||||
--hash=sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613
|
||||
# via
|
||||
# mkdocs
|
||||
# neoteroi-mkdocs
|
||||
|
|
@ -202,9 +202,9 @@ gitdb==4.0.12 \
|
|||
--hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \
|
||||
--hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf
|
||||
# via gitpython
|
||||
gitpython==3.1.46 \
|
||||
--hash=sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f \
|
||||
--hash=sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058
|
||||
gitpython==3.1.50 \
|
||||
--hash=sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc \
|
||||
--hash=sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9
|
||||
# via mkdocs-git-revision-date-localized-plugin
|
||||
griffelib==2.0.0 \
|
||||
--hash=sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f
|
||||
|
|
@ -227,9 +227,9 @@ httpx==0.28.1 \
|
|||
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
|
||||
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
|
||||
# via neoteroi-mkdocs
|
||||
idna==3.11 \
|
||||
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
|
||||
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
|
||||
idna==3.15 \
|
||||
--hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 \
|
||||
--hash=sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# anyio
|
||||
|
|
@ -261,9 +261,9 @@ markdown==3.10.2 \
|
|||
# mkdocstrings
|
||||
# properdocs
|
||||
# pymdown-extensions
|
||||
markdown-it-py==4.0.0 \
|
||||
--hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \
|
||||
--hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3
|
||||
markdown-it-py==4.2.0 \
|
||||
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
|
||||
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
|
||||
# via rich
|
||||
markupsafe==3.0.3 \
|
||||
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
|
||||
|
|
@ -398,13 +398,13 @@ mkdocs-get-deps==0.2.2 \
|
|||
--hash=sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1 \
|
||||
--hash=sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650
|
||||
# via mkdocs
|
||||
mkdocs-git-revision-date-localized-plugin==1.5.1 \
|
||||
--hash=sha256:2b0239455cd84784dd87ac8dfc9253fe4b2dd35e102696f21b5d34e2175981c6 \
|
||||
--hash=sha256:b00fd36ed0f9b2326b1488fd8fa31bf2ce64e68c4aa60a9ce857f10719571903
|
||||
mkdocs-git-revision-date-localized-plugin==1.5.2 \
|
||||
--hash=sha256:0b3d65abdb8b82591d724c1d5ece6af7bb3cd305bdd47e2fadd430886a9a2513 \
|
||||
--hash=sha256:4f3175e039bc4fe0055cac97d295ce8d0d233a13d65986d42fd5324e4985acc2
|
||||
# via -r docs/requirements.in
|
||||
mkdocs-include-markdown-plugin==7.2.2 \
|
||||
--hash=sha256:f052ccb741eccf498116b826c1d78a2d761c56747372594709441cee0963fbc9 \
|
||||
--hash=sha256:f2ec4487cf32d3e33ca528f9366f20fb9280ded9c8d1630eb2bbda244962dcd1
|
||||
mkdocs-include-markdown-plugin==7.3.0 \
|
||||
--hash=sha256:2800126746452e31c2e321bbd43c8190b356e0de353e20cbc16a34a3c3d6796c \
|
||||
--hash=sha256:5b5c99b5d3c9b9ce0114a9e60353bbafb6be53a26c2d3b74ec6b767a7a8e55ca
|
||||
# via -r docs/requirements.in
|
||||
mkdocs-macros-plugin==1.5.0 \
|
||||
--hash=sha256:12aa45ce7ecb7a445c66b9f649f3dd05e9b92e8af6bc65e4acd91d26f878c01f \
|
||||
|
|
@ -430,9 +430,9 @@ mkdocs-simple-hooks==0.1.5 \
|
|||
--hash=sha256:dddbdf151a18723c9302a133e5cf79538be8eb9d274e8e07d2ac3ac34890837c \
|
||||
--hash=sha256:efeabdbb98b0850a909adee285f3404535117159d5cb3a34f541d6eaa644d50a
|
||||
# via -r docs/requirements.in
|
||||
mkdocstrings[python]==1.0.3 \
|
||||
--hash=sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046 \
|
||||
--hash=sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434
|
||||
mkdocstrings[python]==1.0.4 \
|
||||
--hash=sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172 \
|
||||
--hash=sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b
|
||||
# via
|
||||
# -r docs/requirements.in
|
||||
# mkdocstrings-python
|
||||
|
|
@ -444,9 +444,9 @@ neoteroi-mkdocs==1.2.0 \
|
|||
--hash=sha256:58e25cb1b9db093ffa8d12bdb33264bf567cac30fb964b56e0a493efa749ad6e \
|
||||
--hash=sha256:91b6aa95a4e46c9bb93e00e021d2044cb0c7d80c0b4600434ff8f440d613a0a8
|
||||
# via -r docs/requirements.in
|
||||
packaging==26.0 \
|
||||
--hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \
|
||||
--hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# mkdocs
|
||||
|
|
@ -480,9 +480,9 @@ pygments==2.20.0 \
|
|||
# via
|
||||
# mkdocs-material
|
||||
# rich
|
||||
pymdown-extensions==10.21.2 \
|
||||
--hash=sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638 \
|
||||
--hash=sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc
|
||||
pymdown-extensions==10.21.3 \
|
||||
--hash=sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354 \
|
||||
--hash=sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6
|
||||
# via
|
||||
# mkdocs-material
|
||||
# mkdocs-mermaid2-plugin
|
||||
|
|
@ -583,17 +583,17 @@ pyyaml-env-tag==1.1 \
|
|||
# via
|
||||
# mkdocs
|
||||
# properdocs
|
||||
requests==2.33.1 \
|
||||
--hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \
|
||||
--hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a
|
||||
requests==2.34.2 \
|
||||
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
|
||||
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# mkdocs-macros-plugin
|
||||
# mkdocs-material
|
||||
# mkdocs-mermaid2-plugin
|
||||
rich==14.3.3 \
|
||||
--hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \
|
||||
--hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b
|
||||
rich==15.0.0 \
|
||||
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
|
||||
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
|
||||
# via neoteroi-mkdocs
|
||||
setuptools==82.0.1 \
|
||||
--hash=sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9 \
|
||||
|
|
@ -631,9 +631,9 @@ typing-extensions==4.15.0 \
|
|||
# -c src/backend/requirements.txt
|
||||
# anyio
|
||||
# beautifulsoup4
|
||||
urllib3==2.6.3 \
|
||||
--hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \
|
||||
--hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4
|
||||
urllib3==2.7.0 \
|
||||
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
|
||||
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
|
|
|
|||
|
|
@ -109,16 +109,18 @@ unresolved-reference="ignore" # 21 # see https://github.com/astral-sh/ty/issues
|
|||
unresolved-attribute="ignore" # 505 # need Plugin Mixin typing
|
||||
call-non-callable="ignore" # 8 ##
|
||||
invalid-assignment="ignore" # 17 # need to wait for better django field stubs
|
||||
invalid-method-override="ignore" # 99
|
||||
# invalid-method-override="ignore" # 99
|
||||
invalid-return-type="ignore" # 22 ##
|
||||
possibly-missing-attribute="ignore" # 25 # https://github.com/astral-sh/ty/issues/164
|
||||
# possibly-missing-attribute="ignore" # 25 # https://github.com/astral-sh/ty/issues/164
|
||||
unknown-argument="ignore" # 3 # need to wait for better django field stubs
|
||||
invalid-argument-type="ignore" # 49
|
||||
no-matching-overload="ignore" # 3 # need to wait for betterdjango field stubs
|
||||
possibly-unbound-attribute="ignore" # 21
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/backend/InvenTree", "InvenTree"]
|
||||
dynamic_context = "test_function"
|
||||
omit = ["*/test_migrations.py"]
|
||||
|
||||
[tool.coverage.html]
|
||||
show_contexts = true
|
||||
|
|
|
|||
|
|
@ -1,13 +1,70 @@
|
|||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 479
|
||||
INVENTREE_API_VERSION = 496
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v496 -> 2026-05-26 : https://github.com/inventree/InvenTree/pull/12011
|
||||
- Add "creation_date" field to the StockItem API endpoint
|
||||
|
||||
v495 -> 2026-05-25 : https://github.com/inventree/InvenTree/pull/12000
|
||||
- Adds "auto-allocate" API endpoint for sales orders
|
||||
- Allow bulk-delete of SalesOrderAllocation objects via the API
|
||||
- Add new allocation options to the Build auto-allocate API endpoint
|
||||
|
||||
v494 -> 2026-05-23 : https://github.com/inventree/InvenTree/pull/11990
|
||||
- Offload build output operations to a background task, and return a task ID which can be used to monitor the progress of the task
|
||||
|
||||
v493 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11961
|
||||
- Adds "thumbnail" field to the Attachment API endpoint, which provides a URL to a thumbnail image for image attachments (if available)
|
||||
|
||||
v492 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11281
|
||||
- Add Transfer Order model and associated API endpoint
|
||||
|
||||
v491 -> 2026-05-21 : https://github.com/inventree/InvenTree/pull/11979
|
||||
- Add API serializer for deleting a part category
|
||||
- Add API serializer for deleting a stock location
|
||||
|
||||
v490 -> 2026-05-19 : https://github.com/inventree/InvenTree/pull/11963
|
||||
- Moves user-self-filtered endpoints to /user/me/ to make their security boundaries clearer
|
||||
|
||||
v489 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11962
|
||||
- Removes the "remote_image" field from the Part API endpoint
|
||||
- Removes the "remote_image" field from the Company API endpoint
|
||||
|
||||
v488 -> 2026-05-17 : https://github.com/inventree/InvenTree/pull/11920
|
||||
- Allow renaming of attachments after upload via the API
|
||||
|
||||
v487 -> 2026-05-15 : https://github.com/inventree/InvenTree/pull/11948
|
||||
- Make SelectionList default nullable
|
||||
- Add icon to TreePath schema
|
||||
|
||||
v486 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11914
|
||||
- Adds "maximum_stock" field to the Part model and associated API endpoints
|
||||
- Adds "high_stock" filter to the Part API endpoint to filter parts which are above their maximum stock level
|
||||
|
||||
v485 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11631
|
||||
- Adds "raw_amount" field to the BomItem API endpoint
|
||||
|
||||
v484 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11910
|
||||
- Adds more docstrings to (scheduled) tasks
|
||||
|
||||
v483 -> 2026-05-04 : https://github.com/inventree/InvenTree/pull/11861
|
||||
- Enable bulk-update operations on the BomItem API endpoint, allowing multiple BOM items to be updated in a single API call
|
||||
|
||||
v482 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11540
|
||||
- Add id to the ordering fields of the Parts model
|
||||
|
||||
v481 -> 2026-04-28 : https://github.com/inventree/InvenTree/pull/11825
|
||||
- Adds new "bom" ruleset and associated permissions for BOM management, separate from the "part" ruleset which remains focused on part management
|
||||
|
||||
v480 -> 2026-04-27 : https://github.com/inventree/InvenTree/pull/11816
|
||||
- The "issued_by" field on the Build API endpoint is now read-only, and is automatically set to the current user when a build is created
|
||||
|
||||
v479 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11723
|
||||
- POST /api//notifications/readall/ now requires a POST action
|
||||
- POST /api/notifications/readall/ now requires a POST action
|
||||
- POST /api/admin/email/test/ - now returns a 200 on. a successful test
|
||||
- GET /api/notifications/ - now uses user-centric permissions, not a general read
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import shutil
|
|||
import string
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
CONFIG_DATA = None
|
||||
|
|
@ -254,6 +254,24 @@ def do_typecast(value, type, var_name=None):
|
|||
return value
|
||||
|
||||
|
||||
def get_config_value(config_key: str) -> Optional[Any]:
|
||||
"""Helper function to retrieve a configuration value from the config file."""
|
||||
cfg_data = load_config_data()
|
||||
|
||||
result = None
|
||||
|
||||
# Hack to allow 'path traversal' in configuration file
|
||||
for key in config_key.strip().split('.'):
|
||||
if type(cfg_data) is not dict or key not in cfg_data:
|
||||
result = None
|
||||
break
|
||||
|
||||
result = cfg_data[key]
|
||||
cfg_data = cfg_data[key]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_setting(env_var=None, config_key=None, default_value=None, typecast=None):
|
||||
"""Helper function for retrieving a configuration setting value.
|
||||
|
||||
|
|
@ -291,18 +309,7 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
|
|||
|
||||
# Next, try to load from configuration file
|
||||
if config_key is not None:
|
||||
cfg_data = load_config_data()
|
||||
|
||||
result = None
|
||||
|
||||
# Hack to allow 'path traversal' in configuration file
|
||||
for key in config_key.strip().split('.'):
|
||||
if type(cfg_data) is not dict or key not in cfg_data:
|
||||
result = None
|
||||
break
|
||||
|
||||
result = cfg_data[key]
|
||||
cfg_data = cfg_data[key]
|
||||
result = get_config_value(config_key)
|
||||
|
||||
if result is not None:
|
||||
set_metadata('yaml')
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ def from_engineering_notation(value):
|
|||
return value
|
||||
|
||||
|
||||
def convert_value(value, unit):
|
||||
def convert_value(value, unit=None):
|
||||
"""Attempt to convert a value to a specified unit.
|
||||
|
||||
Arguments:
|
||||
|
|
@ -221,11 +221,11 @@ def convert_physical_value(value: str, unit: Optional[str] = None, strip_units=T
|
|||
unit: Optional unit to convert to, and validate against
|
||||
strip_units: If True, strip units from the returned value, and return only the dimension
|
||||
|
||||
Raises:
|
||||
ValidationError: If the value is invalid or cannot be converted to the specified unit
|
||||
|
||||
Returns:
|
||||
The converted quantity, in the specified units
|
||||
|
||||
Raises:
|
||||
ValidationError: If the value is invalid or cannot be converted to the specified unit
|
||||
"""
|
||||
ureg = get_unit_registry()
|
||||
|
||||
|
|
|
|||
|
|
@ -1181,3 +1181,18 @@ def plugins_info(*args, **kwargs):
|
|||
return [
|
||||
{'name': plg.name, 'slug': plg.slug, 'version': plg.version} for plg in plugins
|
||||
]
|
||||
|
||||
|
||||
def sanitize_token(token_value: str, front=8, back=12) -> str:
|
||||
"""Sanitize a token by replacing the middle characters with asterisks.
|
||||
|
||||
Args:
|
||||
token_value: The token string to sanitize
|
||||
front: Number of characters to show at the start of the token (default = 8)
|
||||
back: Number of characters to show at the end of the token (default = 12)
|
||||
|
||||
Returns:
|
||||
The sanitized token string
|
||||
"""
|
||||
middle = len(token_value) - (front + back)
|
||||
return token_value[:front] + '*' * middle + token_value[-back:]
|
||||
|
|
|
|||
|
|
@ -120,7 +120,12 @@ def validate_url_no_ssrf(url):
|
|||
raise ValueError(_('URL points to a private or reserved IP address'))
|
||||
|
||||
|
||||
def download_image_from_url(remote_url, timeout=2.5):
|
||||
def download_image_from_url(
|
||||
remote_url: str,
|
||||
timeout: float = 2.5,
|
||||
user_agent: str = '',
|
||||
max_size: Optional[int] = None,
|
||||
):
|
||||
"""Download an image file from a remote URL.
|
||||
|
||||
This is a potentially dangerous operation, so we must perform some checks:
|
||||
|
|
@ -130,8 +135,9 @@ def download_image_from_url(remote_url, timeout=2.5):
|
|||
|
||||
Arguments:
|
||||
remote_url: The remote URL to retrieve image
|
||||
max_size: Maximum allowed image size (default = 1MB)
|
||||
timeout: Connection timeout in seconds (default = 5)
|
||||
user_agent: User-Agent string to use for the request (optional)
|
||||
max_size: Maximum allowed image size (in bytes) (default = 1MB)
|
||||
|
||||
Returns:
|
||||
An in-memory PIL image file, if the download was successful
|
||||
|
|
@ -151,13 +157,9 @@ def download_image_from_url(remote_url, timeout=2.5):
|
|||
validate_url_no_ssrf(remote_url)
|
||||
|
||||
# Calculate maximum allowable image size (in bytes)
|
||||
max_size = (
|
||||
int(get_global_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024
|
||||
)
|
||||
max_size = max_size or 1 * 1024 * 1024 # Default to 1MB if not provided
|
||||
|
||||
# Add user specified user-agent to request (if specified)
|
||||
user_agent = get_global_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT')
|
||||
|
||||
headers = {'User-Agent': user_agent} if user_agent else None
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ from django.http import HttpRequest, HttpResponse, JsonResponse
|
|||
from django.shortcuts import redirect, render
|
||||
from django.urls import resolve, reverse, reverse_lazy
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.http import is_same_domain
|
||||
from django.utils.http import is_same_domain, url_has_allowed_host_and_scheme
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import structlog
|
||||
from error_report.middleware import ExceptionProcessor
|
||||
|
||||
import InvenTree.helpers
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.cache import create_session_cache, delete_session_cache
|
||||
from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller
|
||||
|
|
@ -126,7 +127,8 @@ class AuthRequiredMiddleware:
|
|||
return True
|
||||
except ApiToken.DoesNotExist: # pragma: no cover
|
||||
logger.warning(
|
||||
'Access denied for unknown token %s', token
|
||||
'Access denied for unknown token %s',
|
||||
InvenTree.helpers.sanitize_token(str(token)),
|
||||
) # pragma: no cover
|
||||
|
||||
return False
|
||||
|
|
@ -163,9 +165,16 @@ class AuthRequiredMiddleware:
|
|||
if path not in urls and not any(
|
||||
path.startswith(p) for p in paths_ignore_handling
|
||||
):
|
||||
# Validate next url is safe to redirect to
|
||||
next_url = request.path
|
||||
if not url_has_allowed_host_and_scheme(
|
||||
url=next_url,
|
||||
allowed_hosts=settings.ALLOWED_HOSTS,
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
return redirect(str(reverse_lazy('account_login')))
|
||||
# Save the 'next' parameter to pass through to the login view
|
||||
|
||||
return redirect(f'{reverse_lazy("account_login")}?next={request.path}')
|
||||
return redirect(f'{reverse_lazy("account_login")}?next={next_url}')
|
||||
# Return a 401 (Unauthorized) response code for this request
|
||||
return HttpResponse('Unauthorized', status=401)
|
||||
|
||||
|
|
|
|||
|
|
@ -594,7 +594,7 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
|
|||
parameters.append(parameter)
|
||||
|
||||
if len(parameters) > 0:
|
||||
common.models.Parameter.objects.bulk_create(parameters)
|
||||
common.models.Parameter.objects.bulk_create(parameters, batch_size=250)
|
||||
|
||||
def get_parameter(self, name: str):
|
||||
"""Return a Parameter instance for the given parameter name.
|
||||
|
|
@ -661,7 +661,9 @@ class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin):
|
|||
|
||||
Before deleting the model instance, delete any associated attachments.
|
||||
"""
|
||||
self.attachments.all().delete()
|
||||
for attachment in list(self.attachments.all()):
|
||||
attachment.delete()
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ def isAppLoaded(app_name: str) -> bool:
|
|||
|
||||
def isInTestMode():
|
||||
"""Returns True if the database is in testing mode."""
|
||||
return 'test' in sys.argv or sys.argv[0].endswith('pytest')
|
||||
return any(x in sys.argv for x in ['test', 'pytest']) or sys.argv[0].endswith(
|
||||
'pytest'
|
||||
)
|
||||
|
||||
|
||||
def isWaitingForDatabase():
|
||||
|
|
|
|||
|
|
@ -341,3 +341,24 @@ def schema_for_view_output_options(view_class):
|
|||
view_class
|
||||
)
|
||||
return extended_view
|
||||
|
||||
|
||||
def exclude_from_schema(klass: type, alternative_path: str) -> type:
|
||||
"""Decorator to exclude a view from the OpenAPI schema.
|
||||
|
||||
This is used to hide legacy endpoints from the schema, while still retaining them for backwards compatibility.
|
||||
"""
|
||||
|
||||
class LegacyView(klass):
|
||||
"""Dummy doc."""
|
||||
|
||||
LegacyView.__name__ = klass.__name__ + ' - Legacy'
|
||||
LegacyView.__doc__ = f'This is a legacy endpoint, retained for backwards compatibility. Consider migrating to the new endpoint under {alternative_path}.'
|
||||
|
||||
# Exclude all default operations from the schema
|
||||
for operation in ['get', 'post', 'put', 'patch', 'delete']:
|
||||
if hasattr(klass, operation):
|
||||
LegacyView = extend_schema_view(**{operation: extend_schema(exclude=True)})(
|
||||
LegacyView
|
||||
)
|
||||
return LegacyView
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ from rest_framework.serializers import DecimalField, Serializer
|
|||
from rest_framework.utils import model_meta
|
||||
from taggit.serializers import TaggitSerializer
|
||||
|
||||
import common.models as common_models
|
||||
import InvenTree.ready
|
||||
from common.currency import currency_code_default, currency_code_mappings
|
||||
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
|
||||
|
|
@ -315,13 +314,16 @@ class TreePathSerializer(serializers.Serializer):
|
|||
|
||||
allowed_fields = ['pk', 'name', *(extra_fields or [])]
|
||||
|
||||
if InvenTree.ready.isGeneratingSchema():
|
||||
return
|
||||
|
||||
for field in list(self.fields.keys()):
|
||||
if field not in allowed_fields:
|
||||
self.fields.pop(field, None)
|
||||
|
||||
pk = serializers.IntegerField(read_only=True)
|
||||
name = serializers.CharField(read_only=True)
|
||||
icon = serializers.CharField(required=False, read_only=True)
|
||||
icon = serializers.CharField(required=False, read_only=True, allow_null=True)
|
||||
|
||||
|
||||
class InvenTreeMoneySerializer(MoneyField):
|
||||
|
|
@ -782,51 +784,6 @@ class NotesFieldMixin:
|
|||
self.fields.pop('notes', None)
|
||||
|
||||
|
||||
class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
|
||||
"""Mixin class which allows downloading an 'image' from a remote URL.
|
||||
|
||||
Adds the optional, write-only `remote_image` field to the serializer
|
||||
"""
|
||||
|
||||
def skip_create_fields(self):
|
||||
"""Ensure the 'remote_image' field is skipped when creating a new instance."""
|
||||
return ['remote_image']
|
||||
|
||||
remote_image = serializers.URLField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
write_only=True,
|
||||
label=_('Remote Image'),
|
||||
help_text=_('URL of remote image file'),
|
||||
)
|
||||
|
||||
def validate_remote_image(self, url):
|
||||
"""Perform custom validation for the remote image URL.
|
||||
|
||||
- Attempt to download the image and store it against this object instance
|
||||
- Catches and re-throws any errors
|
||||
"""
|
||||
from InvenTree.helpers_model import download_image_from_url
|
||||
|
||||
if not url:
|
||||
return
|
||||
|
||||
if not common_models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_DOWNLOAD_FROM_URL'
|
||||
):
|
||||
raise ValidationError(
|
||||
_('Downloading images from remote URL is not enabled')
|
||||
)
|
||||
|
||||
try:
|
||||
self.remote_image_file = download_image_from_url(url)
|
||||
except Exception:
|
||||
self.remote_image_file = None
|
||||
raise ValidationError(_('Failed to download image from remote URL'))
|
||||
|
||||
return url
|
||||
|
||||
|
||||
class ContentTypeField(serializers.ChoiceField):
|
||||
"""Serializer field which represents a ContentType as 'app_label.model_name'.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,23 +4,44 @@ from pathlib import Path
|
|||
|
||||
import structlog
|
||||
|
||||
from InvenTree.config import get_boolean_setting, get_setting
|
||||
from InvenTree.config import get_boolean_setting, get_config_value, get_setting
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
def get_db_backend():
|
||||
"""Return the database backend configuration."""
|
||||
# For the core database configuration values, we test for UPPERCASE configuration values as a backup,
|
||||
# due to legacy reasons (original config files were uppercase,
|
||||
# but we moved to lowercase for consistency with other settings.
|
||||
|
||||
db_config = {
|
||||
'ENGINE': get_setting('INVENTREE_DB_ENGINE', 'database.engine', None),
|
||||
'NAME': get_setting('INVENTREE_DB_NAME', 'database.name', None),
|
||||
'USER': get_setting('INVENTREE_DB_USER', 'database.user', None),
|
||||
'PASSWORD': get_setting('INVENTREE_DB_PASSWORD', 'database.password', None),
|
||||
'HOST': get_setting('INVENTREE_DB_HOST', 'database.host', None),
|
||||
'PORT': get_setting('INVENTREE_DB_PORT', 'database.port', 5432, typecast=int),
|
||||
'ENGINE': get_setting(
|
||||
'INVENTREE_DB_ENGINE', 'database.engine', '', typecast=str
|
||||
)
|
||||
or get_config_value('database.ENGINE')
|
||||
or '',
|
||||
'NAME': get_setting('INVENTREE_DB_NAME', 'database.name', '', typecast=str)
|
||||
or get_config_value('database.NAME')
|
||||
or '',
|
||||
'USER': get_setting('INVENTREE_DB_USER', 'database.user', '', typecast=str)
|
||||
or get_config_value('database.USER')
|
||||
or '',
|
||||
'PASSWORD': get_setting(
|
||||
'INVENTREE_DB_PASSWORD', 'database.password', '', typecast=str
|
||||
)
|
||||
or get_config_value('database.PASSWORD')
|
||||
or '',
|
||||
'HOST': get_setting('INVENTREE_DB_HOST', 'database.host', '', typecast=str)
|
||||
or get_config_value('database.HOST')
|
||||
or '',
|
||||
'PORT': get_setting('INVENTREE_DB_PORT', 'database.port', '', typecast=str)
|
||||
or get_config_value('database.PORT')
|
||||
or '',
|
||||
'OPTIONS': get_setting(
|
||||
'INVENTREE_DB_OPTIONS', 'database.options', {}, typecast=dict
|
||||
)
|
||||
or get_config_value('database.OPTIONS')
|
||||
or {},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ def check_existing_task(taskname, group: str, *args, **kwargs) -> Optional[str]:
|
|||
|
||||
# Iterate through all available tasks, with the most recent first
|
||||
for task in OrmQ.objects.all().order_by('-id'):
|
||||
if task.func() != taskname and task.task['func'] != taskname:
|
||||
if task.func() != taskname and task.task.get('func') != taskname:
|
||||
# Task does not match
|
||||
continue
|
||||
|
||||
|
|
@ -423,12 +423,12 @@ def scheduled_task(
|
|||
minutes (int, optional): The number of minutes between task runs. Defaults to None.
|
||||
tasklist (TaskRegister, optional): The list the tasks should be registered to. Defaults to None.
|
||||
|
||||
Returns:
|
||||
_type_: _description_
|
||||
|
||||
Raises:
|
||||
ValueError: If decorated object is not callable
|
||||
ValueError: If interval is not valid
|
||||
|
||||
Returns:
|
||||
_type_: _description_
|
||||
"""
|
||||
|
||||
def _task_wrapper(admin_class):
|
||||
|
|
|
|||
|
|
@ -738,21 +738,10 @@ class TestHelpers(TestCase):
|
|||
|
||||
large_img = 'https://github.com/inventree/InvenTree/raw/master/src/backend/InvenTree/InvenTree/static/img/paper_splash_large.jpg'
|
||||
|
||||
InvenTreeSetting.set_setting(
|
||||
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 1, change_user=None
|
||||
)
|
||||
|
||||
# Attempt to download an image which is too large
|
||||
with self.assertRaises(ValueError):
|
||||
InvenTree.helpers_model.download_image_from_url(large_img, timeout=10)
|
||||
|
||||
# Increase allowable download size
|
||||
InvenTreeSetting.set_setting(
|
||||
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 5, change_user=None
|
||||
)
|
||||
|
||||
# Download a valid image (should not throw an error)
|
||||
InvenTree.helpers_model.download_image_from_url(large_img, timeout=10)
|
||||
InvenTree.helpers_model.download_image_from_url(
|
||||
large_img, timeout=10, max_size=10 * 1024 * 1024
|
||||
)
|
||||
|
||||
def test_model_mixin(self):
|
||||
"""Test the getModelsWithMixin function."""
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ import sys
|
|||
from datetime import datetime as dt
|
||||
from datetime import timedelta as td
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
|
|
@ -39,6 +37,8 @@ try:
|
|||
output = logger.warning
|
||||
|
||||
try:
|
||||
from django.conf import settings
|
||||
|
||||
if settings.DOCKER:
|
||||
output = logger.info
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -386,6 +386,20 @@ class BuildList(
|
|||
kwargs['create'] = True
|
||||
return super().get_serializer(*args, **kwargs)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Save user information on order creation."""
|
||||
serializer = self.get_serializer(data=self.clean_data(request.data))
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
build = serializer.save()
|
||||
build.issued_by = request.user
|
||||
build.save()
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
|
||||
class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a Build object."""
|
||||
|
|
@ -721,14 +735,81 @@ class BuildOutputScrap(BuildOrderContextMixin, CreateAPI):
|
|||
ctx['to_complete'] = False
|
||||
return ctx
|
||||
|
||||
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
|
||||
def post(self, *args, **kwargs):
|
||||
"""Override POST to offload scrapping to the background worker."""
|
||||
from build.tasks import scrap_build_outputs
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
build = self.get_build()
|
||||
serializer = self.get_serializer(data=self.request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
task_id = offload_task(
|
||||
scrap_build_outputs,
|
||||
build.pk,
|
||||
outputs=[
|
||||
{
|
||||
'output_id': item['output'].pk,
|
||||
'quantity': float(item['quantity'])
|
||||
if item.get('quantity') is not None
|
||||
else None,
|
||||
}
|
||||
for item in data['outputs']
|
||||
],
|
||||
location_id=data['location'].pk,
|
||||
notes=data.get('notes', ''),
|
||||
discard_allocations=data.get('discard_allocations', False),
|
||||
user_id=self.request.user.pk,
|
||||
group='build',
|
||||
)
|
||||
|
||||
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
|
||||
return Response(response, status=response['http_status'])
|
||||
|
||||
|
||||
class BuildOutputComplete(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for completing build outputs."""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = build.serializers.BuildOutputCompleteSerializer
|
||||
|
||||
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
|
||||
def post(self, *args, **kwargs):
|
||||
"""Override POST to offload build output completion to the background worker."""
|
||||
from build.tasks import complete_build_outputs
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
build = self.get_build()
|
||||
serializer = self.get_serializer(data=self.request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
location = data.get('location')
|
||||
|
||||
task_id = offload_task(
|
||||
complete_build_outputs,
|
||||
build.pk,
|
||||
outputs=[
|
||||
{
|
||||
'output_id': item['output'].pk,
|
||||
'quantity': float(item['quantity'])
|
||||
if item.get('quantity') is not None
|
||||
else None,
|
||||
}
|
||||
for item in data['outputs']
|
||||
],
|
||||
location_id=location.pk if location else None,
|
||||
status=data.get('status_custom_key'),
|
||||
notes=data.get('notes', ''),
|
||||
user_id=self.request.user.pk,
|
||||
group='build',
|
||||
)
|
||||
|
||||
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
|
||||
return Response(response, status=response['http_status'])
|
||||
|
||||
|
||||
class BuildOutputDelete(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for deleting multiple build outputs."""
|
||||
|
|
@ -736,15 +817,33 @@ class BuildOutputDelete(BuildOrderContextMixin, CreateAPI):
|
|||
def get_serializer_context(self):
|
||||
"""Add extra context information to the endpoint serializer."""
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['to_complete'] = False
|
||||
|
||||
return ctx
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = build.serializers.BuildOutputDeleteSerializer
|
||||
|
||||
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
|
||||
def post(self, *args, **kwargs):
|
||||
"""Override POST to offload build output deletion to the background worker."""
|
||||
from build.tasks import delete_build_outputs
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
build = self.get_build()
|
||||
serializer = self.get_serializer(data=self.request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
task_id = offload_task(
|
||||
delete_build_outputs,
|
||||
build.pk,
|
||||
output_ids=[item['output'].pk for item in data['outputs']],
|
||||
group='build',
|
||||
)
|
||||
|
||||
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
|
||||
return Response(response, status=response['http_status'])
|
||||
|
||||
|
||||
class BuildFinish(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for marking a build as finished (completed)."""
|
||||
|
|
@ -787,6 +886,8 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
|
|||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
build_lines = data.get('build_lines', [])
|
||||
|
||||
# Offload the task to the background worker
|
||||
task_id = offload_task(
|
||||
auto_allocate_build,
|
||||
|
|
@ -797,6 +898,8 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
|
|||
substitutes=data['substitutes'],
|
||||
optional_items=data['optional_items'],
|
||||
item_type=data.get('item_type', 'untracked'),
|
||||
stock_sort_by=data['stock_sort_by'],
|
||||
line_ids=[line.pk for line in build_lines] if build_lines else None,
|
||||
group='build',
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ from build.validators import (
|
|||
validate_build_order_reference,
|
||||
)
|
||||
from common.models import ProjectCode
|
||||
from common.notifications import InvenTreeNotificationBodies, trigger_notification
|
||||
from common.settings import (
|
||||
get_global_setting,
|
||||
prevent_build_output_complete_on_incompleted_tests,
|
||||
|
|
@ -403,6 +402,14 @@ class Build(
|
|||
related_name='builds_issued',
|
||||
)
|
||||
|
||||
@property
|
||||
def created_by(self):
|
||||
"""Alias for issued_by field.
|
||||
|
||||
This is used for compatibility with the order models
|
||||
"""
|
||||
return self.issued_by
|
||||
|
||||
responsible = models.ForeignKey(
|
||||
users.models.Owner,
|
||||
on_delete=models.SET_NULL,
|
||||
|
|
@ -643,7 +650,6 @@ class Build(
|
|||
|
||||
return self.is_fully_allocated(tracked=False)
|
||||
|
||||
@transaction.atomic
|
||||
def complete_allocations(self, user) -> None:
|
||||
"""Complete all stock allocations for this build order.
|
||||
|
||||
|
|
@ -654,7 +660,7 @@ class Build(
|
|||
|
||||
# Ensure that there are no longer any BuildItem objects
|
||||
# which point to this Build Order
|
||||
self.allocated_stock.delete()
|
||||
self.allocated_stock.all().delete()
|
||||
|
||||
@transaction.atomic
|
||||
def complete_build(self, user: User, trim_allocated_stock: bool = False):
|
||||
|
|
@ -694,66 +700,20 @@ class Build(
|
|||
_('Cannot complete build order with incomplete outputs')
|
||||
)
|
||||
|
||||
if trim_allocated_stock:
|
||||
self.trim_allocated_stock()
|
||||
# Offload background task to complete build allocations
|
||||
InvenTree.tasks.offload_task(
|
||||
build.tasks.complete_build,
|
||||
self.pk,
|
||||
user.pk if user else None,
|
||||
trim_allocated_stock=trim_allocated_stock,
|
||||
group='build',
|
||||
)
|
||||
|
||||
self.completion_date = InvenTree.helpers.current_date()
|
||||
self.completed_by = user
|
||||
self.status = BuildStatus.COMPLETE.value
|
||||
self.save()
|
||||
|
||||
# Offload task to complete build allocations
|
||||
if not InvenTree.tasks.offload_task(
|
||||
build.tasks.complete_build_allocations,
|
||||
self.pk,
|
||||
user.pk if user else None,
|
||||
group='build',
|
||||
):
|
||||
raise ValidationError(
|
||||
_('Failed to offload task to complete build allocations')
|
||||
)
|
||||
|
||||
# Register an event
|
||||
trigger_event(BuildEvents.COMPLETED, id=self.pk)
|
||||
|
||||
# Notify users that this build has been completed
|
||||
targets = [self.issued_by, self.responsible]
|
||||
|
||||
# Also inform anyone subscribed to the assembly part
|
||||
targets.extend(self.part.get_subscribers())
|
||||
|
||||
# Notify those users interested in the parent build
|
||||
if self.parent:
|
||||
targets.append(self.parent.issued_by)
|
||||
targets.append(self.parent.responsible)
|
||||
|
||||
# Notify users if this build points to a sales order
|
||||
if self.sales_order:
|
||||
targets.append(self.sales_order.created_by)
|
||||
targets.append(self.sales_order.responsible)
|
||||
|
||||
build = self
|
||||
name = _(f'Build order {build} has been completed')
|
||||
|
||||
context = {
|
||||
'build': build,
|
||||
'name': name,
|
||||
'slug': 'build.completed',
|
||||
'message': _('A build order has been completed'),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
self.get_absolute_url()
|
||||
),
|
||||
'template': {'html': 'email/build_order_completed.html', 'subject': name},
|
||||
}
|
||||
|
||||
trigger_notification(
|
||||
build,
|
||||
'build.completed',
|
||||
targets=targets,
|
||||
context=context,
|
||||
target_exclude=[user],
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def issue_build(self):
|
||||
"""Mark the Build as IN PRODUCTION.
|
||||
|
|
@ -831,26 +791,15 @@ class Build(
|
|||
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
||||
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
|
||||
|
||||
if remove_allocated_stock:
|
||||
# Offload task to remove allocated stock
|
||||
if not InvenTree.tasks.offload_task(
|
||||
build.tasks.complete_build_allocations,
|
||||
self.pk,
|
||||
user.pk if user else None,
|
||||
group='build',
|
||||
):
|
||||
raise ValidationError(
|
||||
_('Failed to offload task to complete build allocations')
|
||||
)
|
||||
|
||||
else:
|
||||
self.allocated_stock.all().delete()
|
||||
|
||||
# Remove incomplete outputs (if required)
|
||||
if remove_incomplete_outputs:
|
||||
outputs = self.build_outputs.filter(is_building=True)
|
||||
|
||||
outputs.delete()
|
||||
# Offload background task to take care of the expensive operations
|
||||
InvenTree.tasks.offload_task(
|
||||
build.tasks.cancel_build,
|
||||
self.pk,
|
||||
user.pk if user else None,
|
||||
remove_allocated_stock=remove_allocated_stock,
|
||||
remove_incomplete_outputs=remove_incomplete_outputs,
|
||||
group='build',
|
||||
)
|
||||
|
||||
# Date of 'completion' is the date the build was cancelled
|
||||
self.completion_date = InvenTree.helpers.current_date()
|
||||
|
|
@ -859,17 +808,6 @@ class Build(
|
|||
self.status = BuildStatus.CANCELLED.value
|
||||
self.save()
|
||||
|
||||
# Notify users that the order has been canceled
|
||||
InvenTree.helpers_model.notify_responsible(
|
||||
self,
|
||||
Build,
|
||||
exclude=self.issued_by,
|
||||
content=InvenTreeNotificationBodies.OrderCanceled,
|
||||
extra_users=self.part.get_subscribers(),
|
||||
)
|
||||
|
||||
trigger_event(BuildEvents.CANCELLED, id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def deallocate_stock(self, build_line=None, output=None):
|
||||
"""Deallocate stock from this Build.
|
||||
|
|
@ -967,10 +905,10 @@ class Build(
|
|||
allocations.extend(new_allocations)
|
||||
|
||||
# Bulk create tracking entries
|
||||
stock.models.StockItemTracking.objects.bulk_create(tracking)
|
||||
stock.models.StockItemTracking.objects.bulk_create(tracking, batch_size=250)
|
||||
|
||||
# Generate stock allocations
|
||||
BuildItem.objects.bulk_create(allocations)
|
||||
BuildItem.objects.bulk_create(allocations, batch_size=250)
|
||||
|
||||
else:
|
||||
"""Create a single build output of the given quantity."""
|
||||
|
|
@ -1025,7 +963,9 @@ class Build(
|
|||
self.deallocate_stock(output=output)
|
||||
|
||||
# Remove the build output from the database
|
||||
output.delete()
|
||||
# This is a special case where serialized stock can be deleted,
|
||||
# independedent of the global setting which normally prevents deletion of serialized stock items
|
||||
output.delete(ignore_serial_check=True)
|
||||
|
||||
@transaction.atomic
|
||||
def trim_allocated_stock(self):
|
||||
|
|
@ -1073,7 +1013,6 @@ class Build(
|
|||
"""Returns a QuerySet object of all BuildItem objects which point back to this Build."""
|
||||
return BuildItem.objects.filter(build_line__build=self)
|
||||
|
||||
@transaction.atomic
|
||||
def subtract_allocated_stock(self, user) -> None:
|
||||
"""Removes the allocated untracked items from stock."""
|
||||
# Find all BuildItem objects which point to this build
|
||||
|
|
@ -1381,7 +1320,7 @@ class Build(
|
|||
new_items.extend(self.auto_allocate_tracked_output(output, **kwargs))
|
||||
|
||||
# Bulk-create the new BuildItem objects
|
||||
BuildItem.objects.bulk_create(new_items)
|
||||
BuildItem.objects.bulk_create(new_items, batch_size=250)
|
||||
|
||||
def auto_allocate_untracked_stock(self, **kwargs):
|
||||
"""Automatically allocate untracked stock items against this build order.
|
||||
|
|
@ -1403,6 +1342,8 @@ class Build(
|
|||
interchangeable = kwargs.get('interchangeable', False)
|
||||
substitutes = kwargs.get('substitutes', True)
|
||||
optional_items = kwargs.get('optional_items', False)
|
||||
stock_sort_by = kwargs.get('stock_sort_by', stock.models.STOCK_SORT_DEFAULT)
|
||||
line_ids = kwargs.get('line_ids')
|
||||
|
||||
def stock_sort(item, bom_item, variant_parts):
|
||||
if item.part == bom_item.sub_part:
|
||||
|
|
@ -1414,7 +1355,11 @@ class Build(
|
|||
new_items = []
|
||||
|
||||
# Select only "untracked" line items
|
||||
for line_item in self.untracked_line_items.all():
|
||||
untracked_lines = self.untracked_line_items.all()
|
||||
if line_ids:
|
||||
untracked_lines = untracked_lines.filter(pk__in=line_ids)
|
||||
|
||||
for line_item in untracked_lines:
|
||||
# Find the referenced BomItem
|
||||
bom_item = line_item.bom_item
|
||||
|
||||
|
|
@ -1471,13 +1416,22 @@ class Build(
|
|||
location__in=list(sublocations)
|
||||
)
|
||||
|
||||
# Apply secondary ORM ordering before the Python match-quality stable-sort.
|
||||
if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST:
|
||||
available_stock = available_stock.order_by(
|
||||
F('expiry_date').asc(nulls_last=True)
|
||||
)
|
||||
else:
|
||||
available_stock = available_stock.order_by(stock_sort_by)
|
||||
|
||||
"""
|
||||
Next, we sort the available stock items with the following priority:
|
||||
1. Direct part matches (+1)
|
||||
2. Variant part matches (+2)
|
||||
3. Substitute part matches (+3)
|
||||
|
||||
This ensures that allocation priority is first given to "direct" parts
|
||||
This ensures that allocation priority is first given to "direct" parts.
|
||||
Python's stable sort preserves the secondary ORM ordering within each group.
|
||||
"""
|
||||
available_stock = sorted(
|
||||
available_stock,
|
||||
|
|
@ -1522,7 +1476,7 @@ class Build(
|
|||
break
|
||||
|
||||
# Bulk-create the new BuildItem objects
|
||||
BuildItem.objects.bulk_create(new_items)
|
||||
BuildItem.objects.bulk_create(new_items, batch_size=250)
|
||||
|
||||
def unallocated_lines(self, tracked: Optional[bool] = None) -> QuerySet:
|
||||
"""Returns a list of BuildLine objects which have not been fully allocated."""
|
||||
|
|
@ -1647,7 +1601,7 @@ class Build(
|
|||
|
||||
lines.append(BuildLine(build=self, bom_item=bom_item, quantity=quantity))
|
||||
|
||||
BuildLine.objects.bulk_create(lines)
|
||||
BuildLine.objects.bulk_create(lines, batch_size=250)
|
||||
|
||||
if len(lines) > 0:
|
||||
logger.info('Created %s BuildLine objects for BuildOrder', len(lines))
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import company.serializers
|
|||
import InvenTree.helpers
|
||||
import part.filters
|
||||
import part.serializers as part_serializers
|
||||
import stock.models as stock_models
|
||||
from common.settings import get_global_setting
|
||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||
|
|
@ -104,7 +105,8 @@ class BuildSerializer(
|
|||
read_only_fields = [
|
||||
'completed',
|
||||
'creation_date',
|
||||
'completion_data',
|
||||
'issued_by',
|
||||
'completion_date',
|
||||
'status',
|
||||
'status_text',
|
||||
'level',
|
||||
|
|
@ -461,18 +463,6 @@ class BuildOutputDeleteSerializer(serializers.Serializer):
|
|||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""'save' the serializer to delete the build outputs."""
|
||||
data = self.validated_data
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
with transaction.atomic():
|
||||
for item in outputs:
|
||||
output = item['output']
|
||||
build.delete_output(output)
|
||||
|
||||
|
||||
class BuildOutputScrapSerializer(serializers.Serializer):
|
||||
"""Scrapping one or more build outputs."""
|
||||
|
|
@ -517,27 +507,6 @@ class BuildOutputScrapSerializer(serializers.Serializer):
|
|||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to scrap the build outputs."""
|
||||
build = self.context['build']
|
||||
request = self.context.get('request')
|
||||
data = self.validated_data
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
# Scrap the build outputs
|
||||
with transaction.atomic():
|
||||
for item in outputs:
|
||||
output = item['output']
|
||||
quantity = item.get('quantity', None)
|
||||
build.scrap_build_output(
|
||||
output,
|
||||
quantity,
|
||||
data.get('location', None),
|
||||
user=request.user if request else None,
|
||||
notes=data.get('notes', ''),
|
||||
discard_allocations=data.get('discard_allocations', False),
|
||||
)
|
||||
|
||||
|
||||
class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
"""DRF serializer for completing one or more build outputs."""
|
||||
|
|
@ -609,42 +578,6 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
|||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to complete the build outputs."""
|
||||
build = self.context['build']
|
||||
request = self.context.get('request')
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
location = data.get('location', None)
|
||||
status = data.get('status_custom_key', StockStatus.OK.value)
|
||||
notes = data.get('notes', '')
|
||||
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
# Cache some calculated values which can be passed to each output
|
||||
required_tests = outputs[0]['output'].part.getRequiredTests()
|
||||
prevent_on_incomplete = (
|
||||
common.settings.prevent_build_output_complete_on_incompleted_tests()
|
||||
)
|
||||
|
||||
# Mark the specified build outputs as "complete"
|
||||
with transaction.atomic():
|
||||
for item in outputs:
|
||||
output = item['output']
|
||||
quantity = item.get('quantity', None)
|
||||
|
||||
build.complete_build_output(
|
||||
output,
|
||||
request.user if request else None,
|
||||
quantity=quantity,
|
||||
location=location,
|
||||
status=status,
|
||||
notes=notes,
|
||||
required_tests=required_tests,
|
||||
prevent_on_incomplete=prevent_on_incomplete,
|
||||
)
|
||||
|
||||
|
||||
class BuildIssueSerializer(serializers.Serializer):
|
||||
"""DRF serializer for issuing a build order."""
|
||||
|
|
@ -1133,6 +1066,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
|||
help_text=_('Select item type to auto-allocate'),
|
||||
)
|
||||
|
||||
stock_sort_by = serializers.ChoiceField(
|
||||
default=stock_models.STOCK_SORT_DEFAULT,
|
||||
choices=stock_models.STOCK_SORT_CHOICES,
|
||||
label=_('Stock Priority'),
|
||||
help_text=_('Preferred order in which matching stock items are consumed'),
|
||||
)
|
||||
|
||||
build_lines = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BuildLine.objects.all(),
|
||||
many=True,
|
||||
required=False,
|
||||
default=list,
|
||||
label=_('Build Lines'),
|
||||
help_text=_(
|
||||
'Limit allocation to these build lines (leave blank to allocate all lines)'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BuildItemSerializer(
|
||||
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
|
||||
|
|
|
|||
|
|
@ -100,6 +100,214 @@ def complete_build_allocations(build_id: int, user_id: int):
|
|||
build_order.complete_allocations(user)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('delete_build_outputs')
|
||||
def delete_build_outputs(build_id: int, output_ids: list, **kwargs):
|
||||
"""Delete (cancel) specified build outputs for a BuildOrder.
|
||||
|
||||
Arguments:
|
||||
build_id: The ID of the BuildOrder
|
||||
output_ids: List of StockItem PKs to delete
|
||||
"""
|
||||
from build.models import Build
|
||||
from stock.models import StockItem
|
||||
|
||||
build = Build.objects.get(pk=build_id)
|
||||
|
||||
with transaction.atomic():
|
||||
for output_id in output_ids:
|
||||
output = StockItem.objects.filter(pk=output_id).first()
|
||||
if output:
|
||||
build.delete_output(output)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('scrap_build_outputs')
|
||||
def scrap_build_outputs(
|
||||
build_id: int,
|
||||
outputs: list,
|
||||
location_id: int,
|
||||
notes: str = '',
|
||||
discard_allocations: bool = False,
|
||||
user_id: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Scrap specified build outputs for a BuildOrder.
|
||||
|
||||
Arguments:
|
||||
build_id: The ID of the BuildOrder
|
||||
outputs: List of dicts with 'output_id' and 'quantity'
|
||||
location_id: PK of the destination StockLocation
|
||||
notes: Reason for scrapping
|
||||
discard_allocations: If True, discard (not consume) allocations
|
||||
user_id: PK of the user initiating the action
|
||||
"""
|
||||
from build.models import Build
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
build = Build.objects.get(pk=build_id)
|
||||
location = StockLocation.objects.get(pk=location_id)
|
||||
user = User.objects.filter(pk=user_id).first() if user_id else None
|
||||
|
||||
with transaction.atomic():
|
||||
for item in outputs:
|
||||
output = StockItem.objects.filter(pk=item['output_id']).first()
|
||||
if output:
|
||||
build.scrap_build_output(
|
||||
output,
|
||||
item.get('quantity'),
|
||||
location,
|
||||
user=user,
|
||||
notes=notes,
|
||||
discard_allocations=discard_allocations,
|
||||
)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('complete_build_outputs')
|
||||
def complete_build_outputs(
|
||||
build_id: int,
|
||||
outputs: list,
|
||||
location_id: int | None,
|
||||
status: int,
|
||||
notes: str = '',
|
||||
user_id: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Complete specified build outputs for a BuildOrder.
|
||||
|
||||
Arguments:
|
||||
build_id: The ID of the BuildOrder
|
||||
outputs: List of dicts with 'output_id' and optional 'quantity'
|
||||
location_id: PK of the destination StockLocation (or None)
|
||||
status: Stock status code to assign to completed outputs
|
||||
notes: Completion notes
|
||||
user_id: PK of the user initiating the action
|
||||
"""
|
||||
from build.models import Build
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
build = Build.objects.get(pk=build_id)
|
||||
location = (
|
||||
StockLocation.objects.filter(pk=location_id).first() if location_id else None
|
||||
)
|
||||
user = User.objects.filter(pk=user_id).first() if user_id else None
|
||||
|
||||
required_tests = build.part.getRequiredTests()
|
||||
|
||||
with transaction.atomic():
|
||||
for item in outputs:
|
||||
output = StockItem.objects.filter(pk=item['output_id']).first()
|
||||
if output:
|
||||
build.complete_build_output(
|
||||
output,
|
||||
user,
|
||||
quantity=item.get('quantity'),
|
||||
location=location,
|
||||
status=status,
|
||||
notes=notes,
|
||||
required_tests=required_tests,
|
||||
)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('cancel_build')
|
||||
def cancel_build(
|
||||
build_id: int,
|
||||
user_id: int,
|
||||
remove_allocated_stock: bool = False,
|
||||
remove_incomplete_outputs: bool = False,
|
||||
):
|
||||
"""Tasks to run after a BuildOrder is cancelled.
|
||||
|
||||
Arguments:
|
||||
build_id: The ID of the BuildOrder which has been cancelled
|
||||
user_id: The ID of the user who cancelled the BuildOrder
|
||||
remove_allocated_stock: If True, consume any allocated stock
|
||||
remove_incomplete_outputs: If True, delete any incomplete build outputs
|
||||
|
||||
"""
|
||||
from build.models import Build
|
||||
|
||||
build = Build.objects.get(pk=build_id)
|
||||
|
||||
if remove_allocated_stock:
|
||||
complete_build_allocations(build_id, user_id)
|
||||
else:
|
||||
build.allocated_stock.all().delete()
|
||||
|
||||
if remove_incomplete_outputs:
|
||||
build.build_outputs.filter(is_building=True).delete()
|
||||
|
||||
# Notify users that the order has been canceled
|
||||
InvenTree.helpers_model.notify_responsible(
|
||||
build,
|
||||
Build,
|
||||
exclude=build.issued_by,
|
||||
content=common.notifications.InvenTreeNotificationBodies.OrderCanceled,
|
||||
extra_users=build.part.get_subscribers(),
|
||||
)
|
||||
|
||||
trigger_event(BuildEvents.CANCELLED, id=build.pk)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('complete_build')
|
||||
def complete_build(build_id: int, user_id: int, trim_allocated_stock: bool = False):
|
||||
"""Tasks to run after a BuildOrder is completed.
|
||||
|
||||
Arguments:
|
||||
build_id: The ID of the BuildOrder which has been completed
|
||||
user_id: The ID of the user who completed the BuildOrder
|
||||
trim_allocated_stock: If True, trim any allocated stock which was not consumed
|
||||
"""
|
||||
from build.models import Build
|
||||
|
||||
build = Build.objects.get(pk=build_id)
|
||||
user = User.objects.filter(pk=user_id).first() if user_id else None
|
||||
|
||||
if trim_allocated_stock:
|
||||
build.trim_allocated_stock()
|
||||
|
||||
# Complete any remaining allocations for this build order
|
||||
complete_build_allocations(build_id, user_id)
|
||||
|
||||
# Register an event
|
||||
trigger_event(BuildEvents.COMPLETED, id=build.pk)
|
||||
|
||||
# Notify users that this build has been completed
|
||||
targets = [build.issued_by, build.responsible]
|
||||
|
||||
# Also inform anyone subscribed to the assembly part
|
||||
targets.extend(build.part.get_subscribers())
|
||||
|
||||
# Notify those users interested in the parent build
|
||||
if build.parent:
|
||||
targets.append(build.parent.issued_by)
|
||||
targets.append(build.parent.responsible)
|
||||
|
||||
# Notify users if this build points to a sales order
|
||||
if build.sales_order:
|
||||
targets.append(build.sales_order.created_by)
|
||||
targets.append(build.sales_order.responsible)
|
||||
|
||||
name = _(f'Build order {build} has been completed')
|
||||
|
||||
context = {
|
||||
'build': build,
|
||||
'name': name,
|
||||
'slug': 'build.completed',
|
||||
'message': _('A build order has been completed'),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
build.get_absolute_url()
|
||||
),
|
||||
'template': {'html': 'email/build_order_completed.html', 'subject': name},
|
||||
}
|
||||
|
||||
common.notifications.trigger_notification(
|
||||
build,
|
||||
'build.completed',
|
||||
targets=targets,
|
||||
context=context,
|
||||
target_exclude=[user],
|
||||
)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('update_build_order_lines')
|
||||
def update_build_order_lines(bom_item_pk: int):
|
||||
"""Update all BuildOrderLineItem objects which reference a particular BomItem.
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ from rest_framework import status
|
|||
|
||||
from build.models import Build, BuildItem, BuildLine
|
||||
from build.status_codes import BuildStatus
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import BomItem, Part
|
||||
from stock.models import StockItem
|
||||
from stock.models import StockItem, StockLocation, StockSortOrder
|
||||
from stock.status_codes import StockStatus
|
||||
|
||||
|
||||
|
|
@ -154,7 +155,7 @@ class BuildTest(BuildAPITest):
|
|||
self.post(
|
||||
reverse('api-build-output-complete', kwargs={'pk': 99999}),
|
||||
{},
|
||||
expected_code=400,
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
data = self.post(self.url, {}, expected_code=400).data
|
||||
|
|
@ -225,8 +226,8 @@ class BuildTest(BuildAPITest):
|
|||
'location': 1,
|
||||
'status': StockStatus.ATTENTION.value,
|
||||
},
|
||||
expected_code=201,
|
||||
max_query_count=400,
|
||||
expected_code=200,
|
||||
max_query_count=450,
|
||||
)
|
||||
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
||||
|
|
@ -445,7 +446,7 @@ class BuildTest(BuildAPITest):
|
|||
self.post(
|
||||
delete_url,
|
||||
{'outputs': [{'output': output.pk} for output in outputs[1:3]]},
|
||||
expected_code=201,
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# Two build outputs have been removed
|
||||
|
|
@ -472,7 +473,7 @@ class BuildTest(BuildAPITest):
|
|||
'outputs': [{'output': output.pk} for output in outputs[3:]],
|
||||
'location': 4,
|
||||
},
|
||||
expected_code=201,
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# Check that the outputs have been completed
|
||||
|
|
@ -568,6 +569,9 @@ class BuildTest(BuildAPITest):
|
|||
|
||||
self.assertEqual(bo.children.count(), 0)
|
||||
|
||||
self.assertIsNotNone(bo.issued_by)
|
||||
self.assertEqual(bo.issued_by, self.user)
|
||||
|
||||
|
||||
class BuildAllocationTest(BuildAPITest):
|
||||
"""Unit tests for allocation of stock items against a build order.
|
||||
|
|
@ -1349,15 +1353,16 @@ class BuildOutputCreateTest(BuildAPITest):
|
|||
url, data={'quantity': 5, 'serial_numbers': '1,2,3-5'}, expected_code=201
|
||||
)
|
||||
|
||||
# Build outputs have incdeased
|
||||
# Build outputs have increased
|
||||
self.assertEqual(n_outputs + 5, build.output_count)
|
||||
|
||||
# Stock items have increased
|
||||
self.assertEqual(n_items + 5, part.stock_items.count())
|
||||
|
||||
# Serial numbers have been created
|
||||
# Serial numbers have been created, each with a creation_date
|
||||
for sn in range(1, 6):
|
||||
self.assertTrue(part.stock_items.filter(serial=sn).exists())
|
||||
self.assertIsNotNone(part.stock_items.get(serial=sn).creation_date)
|
||||
|
||||
def test_create_unserialized_output(self):
|
||||
"""Create an unserialized build output via the API."""
|
||||
|
|
@ -1379,6 +1384,9 @@ class BuildOutputCreateTest(BuildAPITest):
|
|||
# Stock items have increased
|
||||
self.assertEqual(n_items + 1, part.stock_items.count())
|
||||
|
||||
# The new output must have a creation_date set
|
||||
self.assertIsNotNone(part.stock_items.order_by('-pk').first().creation_date)
|
||||
|
||||
|
||||
class BuildOutputScrapTest(BuildAPITest):
|
||||
"""Unit tests for scrapping build outputs."""
|
||||
|
|
@ -1462,7 +1470,7 @@ class BuildOutputScrapTest(BuildAPITest):
|
|||
'location': 1,
|
||||
'notes': 'Should succeed',
|
||||
},
|
||||
expected_code=201,
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# There should still be three outputs associated with this build
|
||||
|
|
@ -1530,7 +1538,7 @@ class BuildOutputScrapTest(BuildAPITest):
|
|||
|
||||
# Partially complete the output (with a valid quantity)
|
||||
data['outputs'][0]['quantity'] = 4
|
||||
self.post(url, data, expected_code=201)
|
||||
self.post(url, data, expected_code=200)
|
||||
|
||||
build.refresh_from_db()
|
||||
output.refresh_from_db()
|
||||
|
|
@ -1545,6 +1553,53 @@ class BuildOutputScrapTest(BuildAPITest):
|
|||
self.assertFalse(completed_output.is_building)
|
||||
|
||||
|
||||
class BuildOutputCancelTest(BuildAPITest):
|
||||
"""Test cancellation of build outputs."""
|
||||
|
||||
def test_cancel_output(self):
|
||||
"""Test cancellation of a build output."""
|
||||
build = Build.objects.get(pk=1)
|
||||
build.part.trackable = True
|
||||
build.part.save()
|
||||
|
||||
N = build.build_outputs.count()
|
||||
|
||||
# Create outputs
|
||||
outputs = build.create_build_output(2, serials=['101', '202'])
|
||||
self.assertEqual(outputs.count(), 2)
|
||||
self.assertEqual(build.build_outputs.count(), N + 2)
|
||||
|
||||
output_ids = list(outputs.values_list('pk', flat=True))
|
||||
|
||||
# Let's cancel one of the outputs
|
||||
set_global_setting('STOCK_ALLOW_DELETE_SERIALIZED', True)
|
||||
url = reverse('api-build-output-delete', kwargs={'pk': build.pk})
|
||||
|
||||
self.post(url, data={'outputs': [{'output': output_ids[0]}]}, expected_code=200)
|
||||
|
||||
# Prevent deletion of serialized stock items, and try again
|
||||
# Note that this should still succeed, independent of the global setting
|
||||
set_global_setting('STOCK_ALLOW_DELETE_SERIALIZED', False)
|
||||
|
||||
response = self.post(
|
||||
url, data={'outputs': [{'output': output_ids[1]}]}, expected_code=200
|
||||
)
|
||||
|
||||
# Response should be the task info - the cancellation is performed asynchronously
|
||||
self.assertIn('task_id', response.data)
|
||||
self.assertFalse(response.data['exists'])
|
||||
self.assertFalse(response.data['pending'])
|
||||
self.assertTrue(response.data['complete'])
|
||||
self.assertTrue(response.data['success'])
|
||||
|
||||
# The outputs should have been scrapped
|
||||
self.assertEqual(build.build_outputs.count(), N)
|
||||
|
||||
for pk in output_ids:
|
||||
self.assertFalse(StockItem.objects.filter(pk=pk).exists())
|
||||
self.get(reverse('api-stock-detail', kwargs={'pk': pk}), expected_code=404)
|
||||
|
||||
|
||||
class BuildLineTests(BuildAPITest):
|
||||
"""Unit tests for the BuildLine API endpoints."""
|
||||
|
||||
|
|
@ -1760,3 +1815,162 @@ class BuildConsumeTest(BuildAPITest):
|
|||
|
||||
for line in self.build.build_lines.all():
|
||||
self.assertEqual(line.consumed, 100)
|
||||
|
||||
|
||||
class BuildAutoAllocateAPITest(InvenTreeAPITestCase):
|
||||
"""API integration tests for BuildAutoAllocate endpoint back-ports (stock_sort_by, build_lines)."""
|
||||
|
||||
fixtures = ['company', 'users']
|
||||
|
||||
roles = ['build.add', 'build.change']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Create shared parts, locations and build data for all tests."""
|
||||
super().setUpTestData()
|
||||
|
||||
cls.assembly = Part.objects.create(
|
||||
name='AutoAlloc Assembly', description='', assembly=True
|
||||
)
|
||||
cls.component = Part.objects.create(
|
||||
name='AutoAlloc Component', description='', component=True
|
||||
)
|
||||
cls.component_b = Part.objects.create(
|
||||
name='AutoAlloc Component B', description='', component=True
|
||||
)
|
||||
|
||||
cls.loc_a = StockLocation.objects.create(name='BuildShelf A')
|
||||
cls.loc_b = StockLocation.objects.create(name='BuildShelf B')
|
||||
|
||||
def _next_ref(self):
|
||||
"""Return a valid Build reference using the system-generated next value."""
|
||||
return Build.generate_batch_code()
|
||||
|
||||
def _make_build(self, quantity=10):
|
||||
"""Create a fresh Build with one untracked BOM line (component only)."""
|
||||
build = Build.objects.create(
|
||||
part=self.assembly,
|
||||
reference=f'BO-{9000 + Build.objects.count():04d}',
|
||||
quantity=quantity,
|
||||
)
|
||||
BomItem.objects.create(part=self.assembly, sub_part=self.component, quantity=1)
|
||||
build.create_build_line_items()
|
||||
return build
|
||||
|
||||
def _url(self, pk):
|
||||
return reverse('api-build-auto-allocate', kwargs={'pk': pk})
|
||||
|
||||
def _create_build_two_lines(self, quantity=5):
|
||||
"""Create a Build with two untracked BOM lines (component + component_b)."""
|
||||
build = Build.objects.create(
|
||||
part=self.assembly,
|
||||
reference=f'BO-{9000 + Build.objects.count():04d}',
|
||||
quantity=quantity,
|
||||
)
|
||||
BomItem.objects.create(part=self.assembly, sub_part=self.component, quantity=1)
|
||||
BomItem.objects.create(
|
||||
part=self.assembly, sub_part=self.component_b, quantity=1
|
||||
)
|
||||
build.create_build_line_items()
|
||||
return build
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_invalid_stock_sort_by_rejected(self):
|
||||
"""An unrecognised stock_sort_by value is rejected with 400."""
|
||||
build = self._make_build()
|
||||
self.post(
|
||||
self._url(build.pk), {'stock_sort_by': 'not_valid'}, expected_code=400
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# stock_sort_by behaviour
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_stock_sort_by_quantity_asc(self):
|
||||
"""stock_sort_by=QUANTITY_ASC consumes the smallest lot first."""
|
||||
build = self._make_build(quantity=15)
|
||||
small = StockItem.objects.create(part=self.component, quantity=5)
|
||||
StockItem.objects.create(part=self.component, quantity=100)
|
||||
|
||||
self.post(
|
||||
self._url(build.pk),
|
||||
{
|
||||
'stock_sort_by': str(StockSortOrder.QUANTITY_ASC),
|
||||
'interchangeable': True,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
allocs = BuildItem.objects.filter(build_line__build=build)
|
||||
self.assertTrue(any(a.stock_item == small and a.quantity == 5 for a in allocs))
|
||||
|
||||
def test_stock_sort_by_quantity_desc(self):
|
||||
"""stock_sort_by=QUANTITY_DESC consumes the largest lot first, covering requirement in one allocation."""
|
||||
build = self._make_build(quantity=15)
|
||||
StockItem.objects.create(part=self.component, quantity=5)
|
||||
large = StockItem.objects.create(part=self.component, quantity=100)
|
||||
|
||||
self.post(
|
||||
self._url(build.pk),
|
||||
{
|
||||
'stock_sort_by': str(StockSortOrder.QUANTITY_DESC),
|
||||
'interchangeable': True,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
allocs = BuildItem.objects.filter(build_line__build=build)
|
||||
self.assertEqual(allocs.count(), 1)
|
||||
self.assertEqual(allocs.first().stock_item, large)
|
||||
self.assertEqual(allocs.first().quantity, 15)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# build_lines filtering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_build_lines_subset_only_allocates_selected_lines(self):
|
||||
"""When build_lines is specified only those lines are allocated."""
|
||||
build = self._create_build_two_lines()
|
||||
|
||||
StockItem.objects.create(part=self.component, quantity=50)
|
||||
StockItem.objects.create(part=self.component_b, quantity=50)
|
||||
|
||||
line_a = build.build_lines.filter(bom_item__sub_part=self.component).first()
|
||||
|
||||
self.post(
|
||||
self._url(build.pk),
|
||||
{'build_lines': [line_a.pk], 'interchangeable': True},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
alloc_a = BuildItem.objects.filter(
|
||||
build_line__build=build, stock_item__part=self.component
|
||||
)
|
||||
alloc_b = BuildItem.objects.filter(
|
||||
build_line__build=build, stock_item__part=self.component_b
|
||||
)
|
||||
|
||||
self.assertTrue(alloc_a.exists())
|
||||
self.assertFalse(alloc_b.exists())
|
||||
|
||||
def test_build_lines_empty_allocates_all(self):
|
||||
"""When build_lines is omitted, all untracked lines are allocated."""
|
||||
build = self._create_build_two_lines()
|
||||
|
||||
StockItem.objects.create(part=self.component, quantity=50)
|
||||
StockItem.objects.create(part=self.component_b, quantity=50)
|
||||
|
||||
self.post(self._url(build.pk), {'interchangeable': True}, expected_code=200)
|
||||
|
||||
alloc_a = BuildItem.objects.filter(
|
||||
build_line__build=build, stock_item__part=self.component
|
||||
)
|
||||
alloc_b = BuildItem.objects.filter(
|
||||
build_line__build=build, stock_item__part=self.component_b
|
||||
)
|
||||
|
||||
self.assertTrue(alloc_a.exists())
|
||||
self.assertTrue(alloc_b.exists())
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from InvenTree.unit_test import (
|
|||
from order.models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
|
||||
from stock.models import StockItem, StockItemTestResult, StockLocation
|
||||
from stock.status_codes import StockStatus
|
||||
from users.models import Owner
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
|
@ -557,14 +558,29 @@ class BuildTest(BuildTestBase):
|
|||
bo.clean()
|
||||
|
||||
def test_cancel(self):
|
||||
"""Test cancellation of the build."""
|
||||
# TODO
|
||||
"""
|
||||
self.allocate_stock(50, 50, 200, self.output_1)
|
||||
self.build.cancel_build(None)
|
||||
"""Test build cancellation: status is updated and allocations are removed by default."""
|
||||
self.build.issue_build()
|
||||
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
"""
|
||||
self.allocate_stock(None, {self.stock_1_2: 50})
|
||||
self.assertGreater(self.build.allocated_stock.count(), 0)
|
||||
|
||||
initial_output_count = self.build.build_outputs.filter(is_building=True).count()
|
||||
self.assertGreater(initial_output_count, 0)
|
||||
|
||||
self.build.cancel_build(None)
|
||||
self.build.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.build.status, BuildStatus.CANCELLED)
|
||||
|
||||
# Allocations removed (but stock not consumed) by default
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertIsNone(StockItem.objects.get(pk=self.stock_1_2.pk).consumed_by)
|
||||
|
||||
# Incomplete outputs preserved by default (remove_incomplete_outputs=False)
|
||||
self.assertEqual(
|
||||
self.build.build_outputs.filter(is_building=True).count(),
|
||||
initial_output_count,
|
||||
)
|
||||
|
||||
def test_complete(self):
|
||||
"""Test completion of a build output."""
|
||||
|
|
@ -932,7 +948,10 @@ class ExternalBuildTest(InvenTreeAPITestCase):
|
|||
def test_validation(self):
|
||||
"""Test validation of external build logic."""
|
||||
part = Part.objects.create(
|
||||
name='Test part', description='A test part', purchaseable=False
|
||||
name='Test part',
|
||||
description='A test part',
|
||||
assembly=True,
|
||||
purchaseable=False,
|
||||
)
|
||||
|
||||
# Create a build order
|
||||
|
|
@ -949,6 +968,47 @@ class ExternalBuildTest(InvenTreeAPITestCase):
|
|||
str(err.exception.messages),
|
||||
)
|
||||
|
||||
def test_build_requirement(self):
|
||||
"""Test the global 'BUILDORDER_EXTERNAL_REQUIRED' setting."""
|
||||
# Create required test data
|
||||
part = Part.objects.create(
|
||||
name='Test part',
|
||||
description='A test part',
|
||||
assembly=True,
|
||||
purchaseable=True,
|
||||
)
|
||||
supplier = company.models.Company.objects.create(
|
||||
name='Test supplier', active=True, is_supplier=True
|
||||
)
|
||||
supplier_part = company.models.SupplierPart.objects.create(
|
||||
part=part, supplier=supplier, SKU='TEST-123'
|
||||
)
|
||||
|
||||
po = PurchaseOrder.objects.create(supplier=supplier, reference='PO-9999')
|
||||
po_line = PurchaseOrderLineItem.objects.create(
|
||||
order=po, part=supplier_part, quantity=10
|
||||
)
|
||||
|
||||
set_global_setting('BUILDORDER_EXTERNAL_REQUIRED', False)
|
||||
po_line.clean() # Should not raise an error
|
||||
|
||||
set_global_setting('BUILDORDER_EXTERNAL_BUILDS', True)
|
||||
set_global_setting('BUILDORDER_EXTERNAL_REQUIRED', True)
|
||||
|
||||
# Expect failure, there is no linked build order
|
||||
with self.assertRaises(ValidationError):
|
||||
po_line.clean()
|
||||
|
||||
# Create and link a build order
|
||||
build = Build.objects.create(
|
||||
part=part, title='Test build order', quantity=10, external=True
|
||||
)
|
||||
po_line.build_order = build
|
||||
po_line.save()
|
||||
|
||||
# Clean step now passes
|
||||
po_line.clean()
|
||||
|
||||
def test_logic(self):
|
||||
"""Test external build logic."""
|
||||
# Create a purchaseable assembly part
|
||||
|
|
@ -1063,3 +1123,276 @@ class ExternalBuildTest(InvenTreeAPITestCase):
|
|||
# Filter by 'not external'
|
||||
response = self.get(url, {'external': 'false'})
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
|
||||
class BuildTaskTests(BuildTestBase):
|
||||
"""Direct unit tests for the background task functions in build/tasks.py.
|
||||
|
||||
These tests call task functions directly (synchronously) to verify the
|
||||
business logic they encapsulate, independently of the API and offload mechanism.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create a stock location available to all task tests."""
|
||||
super().setUp()
|
||||
self.location = StockLocation.objects.create(name='Task Test Location')
|
||||
|
||||
def allocate_stock(self, output, allocations):
|
||||
"""Create BuildItem allocations against self.build for the given output."""
|
||||
items_to_create = []
|
||||
for item, quantity in allocations.items():
|
||||
line = BuildLine.objects.filter(
|
||||
build=self.build, bom_item__sub_part=item.part
|
||||
).first()
|
||||
items_to_create.append(
|
||||
BuildItem(
|
||||
build_line=line,
|
||||
stock_item=item,
|
||||
quantity=quantity,
|
||||
install_into=output,
|
||||
)
|
||||
)
|
||||
BuildItem.objects.bulk_create(items_to_create)
|
||||
|
||||
def _setup_complete_build(self):
|
||||
"""Helper: allocate stock fully and complete all outputs so the build is ready to complete."""
|
||||
self.stock_1_1.quantity = 1000
|
||||
self.stock_1_1.save()
|
||||
self.stock_2_1.quantity = 30
|
||||
self.stock_2_1.save()
|
||||
|
||||
self.build.issue_build()
|
||||
|
||||
# Allocate untracked parts
|
||||
self.allocate_stock(
|
||||
None, {self.stock_1_1: 50, self.stock_1_2: 10, self.stock_2_1: 30}
|
||||
)
|
||||
# Allocate tracked parts to each output
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
|
||||
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
self.build.complete_build_output(self.output_2, None)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# cancel_build task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def test_cancel_task_discards_allocations(self):
|
||||
"""cancel_build with remove_allocated_stock=False: allocations deleted, stock not consumed."""
|
||||
self.build.issue_build()
|
||||
self.allocate_stock(None, {self.stock_1_2: 50})
|
||||
self.assertGreater(self.build.allocated_stock.count(), 0)
|
||||
|
||||
build.tasks.cancel_build(
|
||||
self.build.pk, self.user.pk, remove_allocated_stock=False
|
||||
)
|
||||
|
||||
# BuildItem rows gone
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
# Stock item was NOT consumed
|
||||
self.assertIsNone(StockItem.objects.get(pk=self.stock_1_2.pk).consumed_by)
|
||||
|
||||
def test_cancel_task_consumes_allocations(self):
|
||||
"""cancel_build with remove_allocated_stock=True: stock items are marked consumed."""
|
||||
self.build.issue_build()
|
||||
self.allocate_stock(None, {self.stock_1_2: 50})
|
||||
|
||||
build.tasks.cancel_build(
|
||||
self.build.pk, self.user.pk, remove_allocated_stock=True
|
||||
)
|
||||
|
||||
# All BuildItem rows gone
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
# The allocated (non-trackable) stock was consumed
|
||||
self.assertGreater(self.build.consumed_stock.count(), 0)
|
||||
|
||||
def test_cancel_task_removes_incomplete_outputs(self):
|
||||
"""cancel_build with remove_incomplete_outputs=True: in-progress outputs are deleted."""
|
||||
self.build.issue_build()
|
||||
initial_count = self.build.build_outputs.filter(is_building=True).count()
|
||||
self.assertGreater(initial_count, 0)
|
||||
|
||||
build.tasks.cancel_build(
|
||||
self.build.pk, self.user.pk, remove_incomplete_outputs=True
|
||||
)
|
||||
|
||||
self.assertEqual(self.build.build_outputs.filter(is_building=True).count(), 0)
|
||||
|
||||
def test_cancel_task_preserves_incomplete_outputs(self):
|
||||
"""cancel_build with remove_incomplete_outputs=False: in-progress outputs are kept."""
|
||||
self.build.issue_build()
|
||||
initial_count = self.build.build_outputs.filter(is_building=True).count()
|
||||
self.assertGreater(initial_count, 0)
|
||||
|
||||
build.tasks.cancel_build(
|
||||
self.build.pk, self.user.pk, remove_incomplete_outputs=False
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.build.build_outputs.filter(is_building=True).count(), initial_count
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# complete_build task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
TESTING_TABLE_EVENTS=True,
|
||||
PLUGIN_TESTING_EVENTS=True,
|
||||
PLUGIN_TESTING_EVENTS_ASYNC=True,
|
||||
)
|
||||
def test_complete_build_task_triggers_event(self):
|
||||
"""complete_build task fires the BuildEvents.COMPLETED event."""
|
||||
from django_q.models import OrmQ
|
||||
|
||||
from build.events import BuildEvents
|
||||
|
||||
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
|
||||
OrmQ.objects.all().delete()
|
||||
|
||||
self._setup_complete_build()
|
||||
self.build.complete_build(self.user)
|
||||
|
||||
task = findOffloadedEvent(BuildEvents.COMPLETED, matching_kwargs=['id'])
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(task.kwargs()['id'], self.build.pk)
|
||||
|
||||
set_global_setting('ENABLE_PLUGINS_EVENTS', False)
|
||||
|
||||
def test_complete_build_task_trim_stock(self):
|
||||
"""complete_build with trim_allocated_stock=True removes over-allocations before consuming."""
|
||||
self.stock_1_2.quantity = 100
|
||||
self.stock_1_2.save()
|
||||
self.stock_2_1.quantity = 30
|
||||
self.stock_2_1.save()
|
||||
|
||||
self.build.issue_build()
|
||||
|
||||
# Over-allocate sub_part_1: need 50, allocate 100
|
||||
self.allocate_stock(None, {self.stock_1_2: 100, self.stock_2_1: 30})
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
|
||||
|
||||
self.assertTrue(self.build.is_overallocated())
|
||||
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
self.build.complete_build_output(self.output_2, None)
|
||||
self.assertTrue(self.build.can_complete)
|
||||
|
||||
self.build.complete_build(self.user, trim_allocated_stock=True)
|
||||
self.build.refresh_from_db()
|
||||
self.assertEqual(self.build.status, BuildStatus.COMPLETE)
|
||||
|
||||
# Only 50 units of sub_part_1 should have been consumed (not 100)
|
||||
consumed_qty = StockItem.objects.filter(
|
||||
consumed_by=self.build, part=self.sub_part_1
|
||||
).aggregate(total=Sum('quantity'))['total']
|
||||
self.assertEqual(consumed_qty, 50)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# delete_build_outputs task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def test_delete_build_outputs_skips_missing_id(self):
|
||||
"""delete_build_outputs silently skips nonexistent output IDs and deletes valid ones."""
|
||||
from build.tasks import delete_build_outputs
|
||||
|
||||
# Create an output directly to avoid serial-number requirements on the trackable assembly
|
||||
output = StockItem.objects.create(
|
||||
part=self.assembly, quantity=3, is_building=True, build=self.build
|
||||
)
|
||||
real_id = output.pk
|
||||
|
||||
# Mix a valid ID with a nonexistent one — must not raise
|
||||
delete_build_outputs(self.build.pk, [real_id, 99999])
|
||||
|
||||
self.assertFalse(StockItem.objects.filter(pk=real_id).exists())
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# scrap_build_outputs task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def test_scrap_build_outputs_discard_allocations(self):
|
||||
"""scrap_build_outputs with discard_allocations=True removes allocations without consuming stock."""
|
||||
from build.tasks import scrap_build_outputs
|
||||
|
||||
self.build.issue_build()
|
||||
# Allocate tracked stock to output_1
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
self.assertGreater(self.output_1.items_to_install.count(), 0)
|
||||
|
||||
scrap_build_outputs(
|
||||
self.build.pk,
|
||||
[{'output_id': self.output_1.pk, 'quantity': self.output_1.quantity}],
|
||||
location_id=self.location.pk,
|
||||
notes='discard test',
|
||||
discard_allocations=True,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
self.output_1.refresh_from_db()
|
||||
self.assertEqual(self.output_1.status, StockStatus.REJECTED.value)
|
||||
self.assertFalse(self.output_1.is_building)
|
||||
|
||||
# Allocation rows should be gone
|
||||
self.assertEqual(self.output_1.items_to_install.count(), 0)
|
||||
# Stock was discarded (not consumed or installed)
|
||||
self.stock_3_1.refresh_from_db()
|
||||
self.assertIsNone(self.stock_3_1.consumed_by)
|
||||
self.assertIsNone(self.stock_3_1.belongs_to)
|
||||
|
||||
def test_scrap_build_outputs_consume_allocations(self):
|
||||
"""scrap_build_outputs with discard_allocations=False (default) consumes/installs stock."""
|
||||
from build.tasks import scrap_build_outputs
|
||||
|
||||
self.build.issue_build()
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
|
||||
scrap_build_outputs(
|
||||
self.build.pk,
|
||||
[{'output_id': self.output_1.pk, 'quantity': self.output_1.quantity}],
|
||||
location_id=self.location.pk,
|
||||
notes='consume test',
|
||||
discard_allocations=False,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
self.output_1.refresh_from_db()
|
||||
self.assertEqual(self.output_1.status, StockStatus.REJECTED.value)
|
||||
self.assertFalse(self.output_1.is_building)
|
||||
|
||||
# complete_allocation splits stock_3_1 and installs the split piece into output_1
|
||||
# (stock_3_1 quantity=1000, only 6 allocated, so a child item is created)
|
||||
self.assertTrue(
|
||||
StockItem.objects.filter(belongs_to=self.output_1).exists(),
|
||||
'Expected a tracked stock item to be installed into the output',
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# complete_build_outputs task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def test_complete_build_outputs_with_status_none(self):
|
||||
"""complete_build_outputs with status=None falls back to StockStatus.OK in the model."""
|
||||
from build.tasks import complete_build_outputs
|
||||
|
||||
self.build.issue_build()
|
||||
# Create output directly to avoid serial-number requirements on the trackable assembly
|
||||
output = StockItem.objects.create(
|
||||
part=self.assembly, quantity=5, is_building=True, build=self.build
|
||||
)
|
||||
|
||||
complete_build_outputs(
|
||||
self.build.pk,
|
||||
[{'output_id': output.pk}],
|
||||
location_id=self.location.pk,
|
||||
status=None,
|
||||
notes='status none test',
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
output.refresh_from_db()
|
||||
self.assertFalse(output.is_building)
|
||||
# status=None should resolve to StockStatus.OK (the model default)
|
||||
self.assertEqual(output.status, StockStatus.OK.value)
|
||||
|
|
|
|||
|
|
@ -717,7 +717,17 @@ class AttachmentFilter(FilterSet):
|
|||
"""Metaclass options."""
|
||||
|
||||
model = common.models.Attachment
|
||||
fields = ['model_type', 'model_id', 'upload_user']
|
||||
fields = ['model_type', 'model_id', 'upload_user', 'is_image']
|
||||
|
||||
has_thumbnail = rest_filters.BooleanFilter(
|
||||
label=_('Has Thumbnail'), method='filter_has_thumbnail'
|
||||
)
|
||||
|
||||
def filter_has_thumbnail(self, queryset, name, value):
|
||||
"""Filter attachments based on whether they have a thumbnail or not."""
|
||||
if value:
|
||||
return queryset.exclude(thumbnail=None).exclude(thumbnail='')
|
||||
return queryset.filter(Q(thumbnail=None) | Q(thumbnail='')).distinct()
|
||||
|
||||
is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link')
|
||||
|
||||
|
|
@ -781,8 +791,34 @@ class AttachmentList(AttachmentMixin, BulkDeleteMixin, ListCreateAPI):
|
|||
class AttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail API endpoint for Attachment objects."""
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Update an existing attachment object."""
|
||||
attachment = self.get_object()
|
||||
|
||||
if not attachment.check_permission('change', request.user):
|
||||
raise PermissionDenied(
|
||||
_('User does not have permission to edit this attachment')
|
||||
)
|
||||
|
||||
partial = kwargs.pop('partial', False)
|
||||
data = self.clean_data(request.data)
|
||||
|
||||
# Extract filename first
|
||||
filename = data.pop('filename', None)
|
||||
|
||||
# Run other validation / updates first, before attempting to rename the file
|
||||
serializer = self.get_serializer(attachment, data=data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
|
||||
# User is attempting to rename the file
|
||||
if filename and attachment.basename and filename != attachment.basename:
|
||||
attachment.rename(filename)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Check user permissions before deleting an attachment."""
|
||||
"""Delete an existing attachment object."""
|
||||
attachment = self.get_object()
|
||||
|
||||
if not attachment.check_permission('delete', request.user):
|
||||
|
|
@ -1145,6 +1181,10 @@ class SelectionListDetail(RetrieveUpdateDestroyAPI):
|
|||
serializer_class = common.serializers.SelectionListSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method to include entry count."""
|
||||
return self.serializer_class.annotate_queryset(super().get_queryset())
|
||||
|
||||
|
||||
class EntryMixin:
|
||||
"""Mixin for SelectionEntry views."""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
# Generated by Django 5.2.14 on 2026-05-18 11:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import common.models
|
||||
import common.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0041_auto_20251203_1244"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="attachment",
|
||||
name="thumbnail",
|
||||
field=models.ImageField(
|
||||
blank=True,
|
||||
help_text="Thumbnail image for this attachment",
|
||||
null=True,
|
||||
upload_to="",
|
||||
verbose_name="Thumbnail",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="attachment",
|
||||
name="is_image",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if this attachment is a valid image file",
|
||||
verbose_name="Is image",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="attachment",
|
||||
name="attachment",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
help_text="Select file to attach",
|
||||
null=True,
|
||||
upload_to=common.models.rename_attachment,
|
||||
validators=[common.validators.validate_attachment_file],
|
||||
verbose_name="Attachment",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Generated by Django 5.2.14 on 2026-05-18 12:06
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
from tqdm import tqdm
|
||||
|
||||
from time import sleep
|
||||
|
||||
from django.db import migrations
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
|
||||
def update_image_attachments(apps, schema_editor):
|
||||
"""Update existing attachments to ensure that image attachments have thumbnails.
|
||||
|
||||
For each existing attachment, check if it is an image.
|
||||
If it is, generate a thumbnail for it.
|
||||
|
||||
Note: This function mirrors the logic used in the Attachment model's
|
||||
check_is_image method, at the time of writing this migration (2026-05-18).
|
||||
"""
|
||||
|
||||
import common.tasks
|
||||
|
||||
Attachment = apps.get_model('common', 'Attachment')
|
||||
|
||||
# Find all Attachment instances which (potentially) have a file attached
|
||||
attachments = Attachment.objects.exclude(attachment__isnull=True).exclude(attachment='')
|
||||
|
||||
if attachments.count() == 0:
|
||||
return
|
||||
|
||||
progress = tqdm(total=attachments.count(), desc='Migration common.0043: Updating attachments')
|
||||
|
||||
for attachment in attachments:
|
||||
progress.update(1)
|
||||
|
||||
if not attachment.attachment:
|
||||
continue
|
||||
|
||||
if not attachment.attachment.name or not default_storage.exists(attachment.attachment.name):
|
||||
continue
|
||||
|
||||
common.tasks.rebuild_attachment(attachment.pk)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0042_attachment_is_image_attachment_thumbnail"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_image_attachments, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -15,6 +15,7 @@ from datetime import timedelta, timezone
|
|||
from email.utils import make_msgid
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from secrets import compare_digest
|
||||
from typing import Any, Optional
|
||||
|
||||
|
|
@ -25,9 +26,10 @@ from django.contrib.contenttypes.fields import GenericForeignKey
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import SuspiciousFileOperation, ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.utils import validate_file_name
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.core.mail.utils import DNS_NAME
|
||||
from django.core.validators import MinLengthValidator, MinValueValidator
|
||||
|
|
@ -46,6 +48,7 @@ from django_q.signals import post_spawn
|
|||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from opentelemetry import trace
|
||||
from PIL import Image
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
|
|
@ -247,11 +250,14 @@ class BaseInvenTreeSetting(models.Model):
|
|||
|
||||
if len(missing_keys) > 0:
|
||||
logger.info('Building %s default values for %s', len(missing_keys), cls)
|
||||
cls.objects.bulk_create([
|
||||
cls(key=key, value=cls.get_setting_default(key), **kwargs)
|
||||
for key in missing_keys
|
||||
if not key.startswith('_')
|
||||
])
|
||||
cls.objects.bulk_create(
|
||||
[
|
||||
cls(key=key, value=cls.get_setting_default(key), **kwargs)
|
||||
for key in missing_keys
|
||||
if not key.startswith('_')
|
||||
],
|
||||
batch_size=250,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
'Failed to build default values for %s (%s)', cls, type(exc)
|
||||
|
|
@ -1927,6 +1933,8 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
model_id: The ID of the model to which this attachment is linked
|
||||
attachment: The uploaded file
|
||||
url: An external URL
|
||||
thumbnail: A generated thumbnail for the uploaded file (if applicable)
|
||||
is_image: True if this attachment is a valid image file
|
||||
comment: A comment or description for the attachment
|
||||
user: The user who uploaded the attachment
|
||||
upload_date: The date the attachment was uploaded
|
||||
|
|
@ -1935,6 +1943,8 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
tags: Tags for the attachment
|
||||
"""
|
||||
|
||||
THUMBNAIL_SIZE = 256
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
|
|
@ -1945,6 +1955,31 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
|
||||
choice_fnc = common.validators.attachment_model_options
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Custom delete method for the Attachment model.
|
||||
|
||||
- Ensure that the attached file is deleted from storage when the database entry is removed
|
||||
"""
|
||||
attachment = self.attachment
|
||||
thumbnail = self.thumbnail
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
# Delete the associated files from storage (if they exist)W
|
||||
if attachment and default_storage.exists(attachment.name):
|
||||
try:
|
||||
# Remove the attached file from storage
|
||||
default_storage.delete(attachment.name)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
if thumbnail and default_storage.exists(thumbnail.name):
|
||||
try:
|
||||
# Remove the thumbnail file from storage
|
||||
default_storage.delete(thumbnail.name)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom 'save' method for the Attachment model.
|
||||
|
||||
|
|
@ -1952,6 +1987,10 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
- Ensure that the 'content_type' and 'object_id' fields are set
|
||||
- Run extra validations
|
||||
"""
|
||||
import common.tasks
|
||||
|
||||
rebuild = kwargs.pop('rebuild', True)
|
||||
|
||||
# Either 'attachment' or 'link' must be specified!
|
||||
if not self.attachment and not self.link:
|
||||
raise ValidationError({
|
||||
|
|
@ -1979,6 +2018,12 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
if self.file_size != 0:
|
||||
super().save()
|
||||
|
||||
# Offload a background task to update the thumbnail for this attachment
|
||||
if rebuild:
|
||||
InvenTree.tasks.offload_task(
|
||||
common.tasks.rebuild_attachment, self.pk, group='attachments'
|
||||
)
|
||||
|
||||
def clean_svg(self, field):
|
||||
"""Sanitize SVG file before saving."""
|
||||
cleaned = sanitize_svg(field.file.read())
|
||||
|
|
@ -1990,6 +2035,60 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
return os.path.basename(self.attachment.name)
|
||||
return str(self.link)
|
||||
|
||||
def validate_rename(self, filename: str):
|
||||
"""Validate that the provided filename is valid, for renaming an attachment."""
|
||||
filename = filename.strip()
|
||||
|
||||
if not self.attachment:
|
||||
raise ValidationError(_('No file attached to rename'))
|
||||
|
||||
if not filename:
|
||||
raise ValidationError(_('Filename cannot be empty'))
|
||||
|
||||
try:
|
||||
validate_file_name(filename, allow_relative_path=False)
|
||||
except SuspiciousFileOperation:
|
||||
raise ValidationError(_('Invalid filename'))
|
||||
|
||||
current_ext = os.path.splitext(self.attachment.name)[1]
|
||||
new_ext = os.path.splitext(filename)[1]
|
||||
|
||||
if current_ext.lower() != new_ext.lower():
|
||||
raise ValidationError(_('Cannot change file extension'))
|
||||
|
||||
def rename(self, filename: str):
|
||||
"""Rename the attached file."""
|
||||
self.validate_rename(filename)
|
||||
|
||||
old_path = Path(self.attachment.name)
|
||||
new_path = old_path.parent / filename
|
||||
|
||||
if old_path == new_path: # pragma: no cover
|
||||
# No change in filename
|
||||
return
|
||||
|
||||
if not new_path.is_relative_to(old_path.parent): # pragma: no cover
|
||||
raise ValidationError(_('Invalid filename'))
|
||||
|
||||
new_path = new_path.as_posix()
|
||||
|
||||
if default_storage.exists(new_path):
|
||||
raise ValidationError(_('A file with this name already exists'))
|
||||
|
||||
# Create a new file with the new name, and delete the old file
|
||||
new_path = default_storage.save(new_path, self.attachment.file)
|
||||
|
||||
# Ensure that the new file exists
|
||||
if not default_storage.exists(new_path): # pragma: no cover
|
||||
raise ValidationError(_('Failed to save renamed file'))
|
||||
|
||||
# Update the database file path
|
||||
self.attachment.name = new_path
|
||||
self.save()
|
||||
|
||||
# Remove the old path
|
||||
default_storage.delete(old_path)
|
||||
|
||||
model_type = models.CharField(
|
||||
max_length=100,
|
||||
validators=[common.validators.validate_attachment_model_type],
|
||||
|
|
@ -2002,11 +2101,19 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
attachment = models.FileField(
|
||||
upload_to=rename_attachment,
|
||||
verbose_name=_('Attachment'),
|
||||
validators=[common.validators.validate_attachment_file],
|
||||
help_text=_('Select file to attach'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
thumbnail = models.ImageField(
|
||||
verbose_name=_('Thumbnail'),
|
||||
help_text=_('Thumbnail image for this attachment'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
link = InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
null=True,
|
||||
|
|
@ -2039,6 +2146,12 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
help_text=_('Date the file was uploaded'),
|
||||
)
|
||||
|
||||
is_image = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Is image'),
|
||||
help_text=_('True if this attachment is a valid image file'),
|
||||
)
|
||||
|
||||
file_size = models.PositiveIntegerField(
|
||||
default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
|
||||
)
|
||||
|
|
@ -2082,6 +2195,69 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
|
||||
return model_class.check_related_permission(permission, user)
|
||||
|
||||
def check_is_image(self) -> bool:
|
||||
"""Check if the attached file is an image.
|
||||
|
||||
We consider it a valid image if:
|
||||
|
||||
- The file exists in storage
|
||||
- The file can be opened and verified by the PIL library
|
||||
|
||||
"""
|
||||
if not self.attachment:
|
||||
return False
|
||||
|
||||
if not self.attachment.name:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not default_storage.exists(self.attachment.name):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
img_data = default_storage.open(self.attachment.name).read()
|
||||
|
||||
try:
|
||||
Image.open(BytesIO(img_data)).verify()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def generate_thumbnail(self):
|
||||
"""Generate a thumbnail for the attached image."""
|
||||
# Remove any existing thumbnail
|
||||
if self.thumbnail:
|
||||
self.thumbnail.delete(save=False)
|
||||
|
||||
if not self.attachment:
|
||||
return
|
||||
|
||||
if not self.attachment.name or not default_storage.exists(self.attachment.name):
|
||||
return
|
||||
|
||||
# TODO: Offload to plugins, for creating custom thumbnails for different file types
|
||||
# TODO: If a plugin provides a thumbnail, return early
|
||||
|
||||
# Default action is to generate a thumbnail for image files
|
||||
try:
|
||||
img_data = default_storage.open(self.attachment.name).read()
|
||||
except Exception:
|
||||
# No file found, or file cannot be read - cannot generate thumbnail
|
||||
return
|
||||
|
||||
try:
|
||||
img = Image.open(BytesIO(img_data))
|
||||
img.thumbnail((self.THUMBNAIL_SIZE, self.THUMBNAIL_SIZE))
|
||||
thumb_io = BytesIO()
|
||||
img.save(thumb_io, format='PNG')
|
||||
thumb_io.seek(0)
|
||||
|
||||
thumb_name = f'thumb_{os.path.basename(self.attachment.name)}'
|
||||
self.thumbnail.save(thumb_name, ContentFile(thumb_io.read()), save=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class InvenTreeCustomUserStateModel(models.Model):
|
||||
"""Custom model to extends any registered state with extra custom, user defined states.
|
||||
|
|
@ -2609,7 +2785,7 @@ def post_save_parameter_template(sender, instance, created, **kwargs):
|
|||
common.tasks.rebuild_parameters,
|
||||
instance.pk,
|
||||
force_async=True,
|
||||
group='part',
|
||||
group='parameters',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""JSON serializers for common components."""
|
||||
"""API serializers for common components."""
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
|
|
@ -730,9 +730,11 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
|
|||
fields = [
|
||||
'pk',
|
||||
'attachment',
|
||||
'thumbnail',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'is_image',
|
||||
'upload_date',
|
||||
'upload_user',
|
||||
'user_detail',
|
||||
|
|
@ -742,7 +744,14 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
|
|||
'tags',
|
||||
]
|
||||
|
||||
read_only_fields = ['pk', 'file_size', 'upload_date', 'upload_user', 'filename']
|
||||
read_only_fields = [
|
||||
'pk',
|
||||
'file_size',
|
||||
'upload_date',
|
||||
'upload_user',
|
||||
'filename',
|
||||
'is_image',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Override the model_type field to provide dynamic choices."""
|
||||
|
|
@ -759,6 +768,8 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
|
|||
|
||||
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True)
|
||||
|
||||
thumbnail = InvenTreeImageSerializerField(read_only=True, allow_null=True)
|
||||
|
||||
# The 'filename' field must be present in the serializer
|
||||
filename = serializers.CharField(
|
||||
label=_('Filename'), required=False, source='basename', allow_blank=False
|
||||
|
|
@ -995,7 +1006,7 @@ class SelectionListSerializer(InvenTreeModelSerializer):
|
|||
'entry_count',
|
||||
]
|
||||
|
||||
default = SelectionEntrySerializer(read_only=True, many=False)
|
||||
default = SelectionEntrySerializer(read_only=True, allow_null=True, many=False)
|
||||
choices = SelectionEntrySerializer(source='entries', many=True, required=False)
|
||||
entry_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -286,25 +286,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'choices': common.currency.currency_exchange_plugins,
|
||||
'default': 'inventreecurrencyexchange',
|
||||
},
|
||||
'INVENTREE_DOWNLOAD_FROM_URL': {
|
||||
'name': _('Download from URL'),
|
||||
'description': _('Allow download of remote images and files from external URL'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE': {
|
||||
'name': _('Download Size Limit'),
|
||||
'description': _('Maximum allowable download size for remote image'),
|
||||
'INVENTREE_UPLOAD_MAX_SIZE': {
|
||||
'name': _('Upload Size Limit'),
|
||||
'description': _('Maximum allowable upload size for images and files'),
|
||||
'units': 'MB',
|
||||
'default': 1,
|
||||
'validator': [int, MinValueValidator(1), MaxValueValidator(25)],
|
||||
},
|
||||
'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT': {
|
||||
'name': _('User-agent used to download from URL'),
|
||||
'description': _(
|
||||
'Allow to override the user-agent used to download images and files from external URL (leave blank for the default)'
|
||||
),
|
||||
'default': '',
|
||||
'default': 10,
|
||||
'validator': [int, MinValueValidator(1)],
|
||||
},
|
||||
'INVENTREE_STRICT_URLS': {
|
||||
'name': _('Strict URL Validation'),
|
||||
|
|
@ -415,6 +402,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'choices': barcode_plugins,
|
||||
'default': 'inventreebarcode',
|
||||
},
|
||||
'PART_ENABLE_LOCKING': {
|
||||
'name': _('Part Locking'),
|
||||
'description': _('Enable locking of parts to prevent modification'),
|
||||
'validator': bool,
|
||||
'default': True,
|
||||
},
|
||||
'PART_ENABLE_REVISION': {
|
||||
'name': _('Part Revisions'),
|
||||
'description': _('Enable revision field for Part'),
|
||||
|
|
@ -716,6 +709,18 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCK_ALLOW_EDIT_SERIAL': {
|
||||
'name': _('Allow Edit Serial Number'),
|
||||
'description': _('Allow editing of serial number for stock items'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCK_ALLOW_DELETE_SERIALIZED': {
|
||||
'name': _('Delete Serialized Stock'),
|
||||
'description': _('Allow deletion of stock items which have a serial number'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCK_BATCH_CODE_TEMPLATE': {
|
||||
'name': _('Batch Code Template'),
|
||||
'description': _('Template for generating default batch codes for stock items'),
|
||||
|
|
@ -826,6 +831,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BUILDORDER_EXTERNAL_REQUIRED': {
|
||||
'name': _('Require External Build Orders'),
|
||||
'description': _(
|
||||
'Require an external build order when ordering assembled parts from an external supplier'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
|
||||
'name': _('Block Until Tests Pass'),
|
||||
'description': _(
|
||||
|
|
@ -904,6 +917,34 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'TRANSFERORDER_ENABLED': {
|
||||
'name': _('Enable Transfer Orders'),
|
||||
'description': _('Enable transfer order functionality in the user interface'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
'TRANSFERORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Transfer Order Reference Pattern'),
|
||||
'description': _(
|
||||
'Required pattern for generating Transfer Order reference field'
|
||||
),
|
||||
'default': 'TO-{ref:04d}',
|
||||
'validator': order.validators.validate_transfer_order_reference_pattern,
|
||||
},
|
||||
'TRANSFERORDER_REQUIRE_RESPONSIBLE': {
|
||||
'name': _('Require Responsible Owner'),
|
||||
'description': _('A responsible owner must be assigned to each order'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS': {
|
||||
'name': _('Block Incomplete Item Tests'),
|
||||
'description': _(
|
||||
'Prevent allocation of stock items to sales orders if required item tests are incomplete'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'PURCHASEORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Purchase Order Reference Pattern'),
|
||||
'description': _(
|
||||
|
|
@ -1188,6 +1229,20 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'WEEK_STARTS_ON': {
|
||||
'name': _('Week Starts On'),
|
||||
'description': _('Starting day of the week, for display in calendar views'),
|
||||
'default': '1',
|
||||
'choices': [
|
||||
('0', _('Sunday')),
|
||||
('1', _('Monday')),
|
||||
('2', _('Tuesday')),
|
||||
('3', _('Wednesday')),
|
||||
('4', _('Thursday')),
|
||||
('5', _('Friday')),
|
||||
('6', _('Saturday')),
|
||||
],
|
||||
},
|
||||
'TEST_STATION_DATA': {
|
||||
'name': _('Enable Test Station Data'),
|
||||
'description': _('Enable test station data collection for test results'),
|
||||
|
|
|
|||
|
|
@ -251,6 +251,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'SHOW_BOM_SUBASSEMBLY_LEVELS': {
|
||||
'name': _('Show Subassemblies in BOM table'),
|
||||
'description': _('Enable display of subassemblies in the BOM table'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'NOTIFICATION_ERROR_REPORT': {
|
||||
'name': _('Receive error reports'),
|
||||
'description': _('Receive notifications for system errors'),
|
||||
|
|
@ -262,4 +268,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'description': _('Save the last used printing machines for a user'),
|
||||
'default': '',
|
||||
},
|
||||
'DISPLAY_ITEMS_FINAL_LEVEL': {
|
||||
'name': _('Display Items at Final Level'),
|
||||
'description': _(
|
||||
'Automatically default to showing items/parts instead of sub-levels for locations or categories with no children'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,11 +81,11 @@ def set_global_warning(key: str, options: Optional[dict] = None) -> bool:
|
|||
key (str): The key for the warning.
|
||||
options (dict or bool): Options for the warning, or True to set a default warning.
|
||||
|
||||
Raises:
|
||||
ValueError: If the key is not provided.
|
||||
|
||||
Returns:
|
||||
bool: True if the warning was checked / set successfully, False if no check was performed.
|
||||
|
||||
Raises:
|
||||
ValueError: If the key is not provided.
|
||||
"""
|
||||
if not key:
|
||||
raise ValueError('Key must be provided for global warning setting.')
|
||||
|
|
|
|||
|
|
@ -204,3 +204,21 @@ def rebuild_parameters(template_id):
|
|||
|
||||
if n > 0:
|
||||
logger.info("Rebuilt %s parameters for template '%s'", n, template.name)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('rebuild_attachment')
|
||||
def rebuild_attachment(attachment_id: int):
|
||||
"""Rebuild the given attachment, if possible.
|
||||
|
||||
This task is called whenever an attachment is saved, and perform the following tasks:
|
||||
|
||||
- Check if the attachment is an image file, and update the "is_image" field accordingly
|
||||
- Attempt to generate a thumbnail for the attachment
|
||||
"""
|
||||
from common.models import Attachment
|
||||
|
||||
attachment = Attachment.objects.get(pk=attachment_id)
|
||||
|
||||
attachment.is_image = attachment.check_is_image()
|
||||
attachment.generate_thumbnail()
|
||||
attachment.save(rebuild=False)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
"""API unit tests for InvenTree common functionality."""
|
||||
|
||||
import io
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import common.models
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
|
||||
|
|
@ -675,3 +682,320 @@ class ParameterAPITests(InvenTreeAPITestCase):
|
|||
self.assertFalse(
|
||||
common.models.Parameter.objects.filter(template=template.pk).exists()
|
||||
)
|
||||
|
||||
|
||||
class AttachmentAPITests(InvenTreeAPITestCase):
|
||||
"""Tests for the Attachment API."""
|
||||
|
||||
def test_attachments(self):
|
||||
"""Test API functionality for attachments."""
|
||||
from common.models import Attachment
|
||||
from part.models import Part
|
||||
|
||||
self.assignRole('part.add')
|
||||
|
||||
part = Part.objects.create(name='Test Part', description='A part for testing')
|
||||
|
||||
N = Attachment.objects.count()
|
||||
|
||||
# Upload multiple attachments against the part instance
|
||||
for ii in range(5):
|
||||
file_object = io.StringIO('Hello world')
|
||||
file_object.seek(0)
|
||||
|
||||
fn = f'test_file_{ii}.txt'
|
||||
|
||||
content_file = ContentFile(file_object.read(), name=fn)
|
||||
|
||||
url = reverse('api-attachment-list')
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
data={
|
||||
'model_type': 'part',
|
||||
'model_id': part.pk,
|
||||
'attachment': content_file,
|
||||
'comment': f'This is test file {ii}',
|
||||
},
|
||||
format='multipart',
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
data = response.data
|
||||
|
||||
# Check that the file has actually been created
|
||||
self.assertEqual(data['filename'], fn)
|
||||
self.assertTrue(
|
||||
default_storage.exists(data['attachment'].replace('/media/', ''))
|
||||
)
|
||||
|
||||
# Check that we have the expected number of attachments
|
||||
self.assertEqual(Attachment.objects.count(), N + 5)
|
||||
self.assertEqual(part.attachments.count(), 5)
|
||||
|
||||
# Let's rename one of the attachments
|
||||
att = part.attachments.first()
|
||||
self.assertEqual(att.basename, 'test_file_0.txt')
|
||||
|
||||
url = reverse('api-attachment-detail', kwargs={'pk': att.pk})
|
||||
|
||||
# A few failed attempts
|
||||
for new_name in [
|
||||
'different_ext.docx',
|
||||
'test_file_1.txt',
|
||||
'../../test_file.txt',
|
||||
]:
|
||||
print('- ATTEMPTING:', new_name)
|
||||
response = self.patch(url, data={'filename': new_name}, expected_code=400)
|
||||
|
||||
att.refresh_from_db()
|
||||
self.assertEqual(att.basename, 'test_file_0.txt')
|
||||
|
||||
# Let's try seriously this time
|
||||
new_name = 'a_new_file.txt'
|
||||
response = self.patch(url, data={'filename': new_name}, expected_code=200)
|
||||
|
||||
att.refresh_from_db()
|
||||
self.assertEqual(att.basename, new_name)
|
||||
|
||||
# Check that the file has been renamed on disk
|
||||
self.assertTrue(
|
||||
default_storage.exists(f'attachments/part/{part.pk}/{new_name}')
|
||||
)
|
||||
self.assertFalse(
|
||||
default_storage.exists(f'attachments/part/{part.pk}/test_file_0.txt')
|
||||
)
|
||||
|
||||
# Next, let's delete the attachment manually - via the API
|
||||
response = self.delete(url, expected_code=403)
|
||||
self.assignRole('part.delete')
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# Check that the file has been deleted from disk
|
||||
self.assertFalse(
|
||||
default_storage.exists(f'attachments/part/{part.pk}/{new_name}')
|
||||
)
|
||||
|
||||
self.assertEqual(Attachment.objects.count(), N + 4)
|
||||
self.assertEqual(part.attachments.count(), 4)
|
||||
|
||||
# Fetch the remaining attachments
|
||||
attachments = list(part.attachments.all())
|
||||
|
||||
# Now, delete the part instance
|
||||
part.active = False
|
||||
part.save()
|
||||
part.delete()
|
||||
|
||||
self.assertEqual(Attachment.objects.count(), N)
|
||||
|
||||
for att in attachments:
|
||||
# Ensure that the file associated with each attachment has been removed
|
||||
self.assertFalse(default_storage.exists(att.attachment.path))
|
||||
|
||||
|
||||
class AttachmentThumbnailAPITests(InvenTreeAPITestCase):
|
||||
"""Tests for thumbnail generation when uploading attachments via the API."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up a Part instance and required roles."""
|
||||
from part.models import Part
|
||||
|
||||
super().setUp()
|
||||
self.assignRole('part.add')
|
||||
self.assignRole('part.delete')
|
||||
self.part = Part.objects.create(
|
||||
name='Thumbnail Test Part', description='Part for thumbnail testing'
|
||||
)
|
||||
|
||||
def _make_image_file(self, name='test.png', size=(100, 100), color='red'):
|
||||
"""Return a SimpleUploadedFile containing a valid PNG image."""
|
||||
buf = io.BytesIO()
|
||||
Image.new('RGB', size, color=color).save(buf, format='PNG')
|
||||
return SimpleUploadedFile(name, buf.getvalue(), content_type='image/png')
|
||||
|
||||
def _upload_attachment(self, file_obj, expected_code=201):
|
||||
"""Upload a file attachment against the test part and return the response."""
|
||||
return self.post(
|
||||
reverse('api-attachment-list'),
|
||||
data={
|
||||
'model_type': 'part',
|
||||
'model_id': self.part.pk,
|
||||
'attachment': file_obj,
|
||||
},
|
||||
format='multipart',
|
||||
expected_code=expected_code,
|
||||
)
|
||||
|
||||
def test_thumbnail_valid_image(self):
|
||||
"""Uploading a valid image file should set is_image=True and generate a thumbnail."""
|
||||
from common.models import Attachment
|
||||
|
||||
response = self._upload_attachment(self._make_image_file())
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertTrue(att.is_image)
|
||||
self.assertTrue(att.thumbnail)
|
||||
self.assertTrue(default_storage.exists(att.thumbnail.name))
|
||||
|
||||
def test_thumbnail_invalid_image(self):
|
||||
"""Uploading a file with an image extension but invalid image data should not create a thumbnail."""
|
||||
from common.models import Attachment
|
||||
|
||||
bad_file = SimpleUploadedFile(
|
||||
'corrupt.png', b'this is not image data', content_type='image/png'
|
||||
)
|
||||
response = self._upload_attachment(bad_file)
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
def test_thumbnail_non_image_file(self):
|
||||
"""Uploading a non-image file should leave is_image=False with no thumbnail."""
|
||||
from common.models import Attachment
|
||||
|
||||
txt_file = SimpleUploadedFile(
|
||||
'document.txt', b'Hello, InvenTree!', content_type='text/plain'
|
||||
)
|
||||
response = self._upload_attachment(txt_file)
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
def test_thumbnail_large_image(self):
|
||||
"""A large image attachment should produce a thumbnail no larger than THUMBNAIL_SIZE on each side."""
|
||||
from common.models import Attachment
|
||||
|
||||
response = self._upload_attachment(self._make_image_file(size=(1000, 1000)))
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertTrue(att.is_image)
|
||||
self.assertTrue(att.thumbnail)
|
||||
|
||||
thumb_data = default_storage.open(att.thumbnail.name).read()
|
||||
thumb_img = Image.open(io.BytesIO(thumb_data))
|
||||
self.assertLessEqual(thumb_img.width, Attachment.THUMBNAIL_SIZE)
|
||||
self.assertLessEqual(thumb_img.height, Attachment.THUMBNAIL_SIZE)
|
||||
|
||||
def test_thumbnail_deleted_with_attachment(self):
|
||||
"""Deleting an attachment via the API should also remove its thumbnail from storage."""
|
||||
from common.models import Attachment
|
||||
|
||||
response = self._upload_attachment(self._make_image_file())
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertTrue(att.thumbnail)
|
||||
thumb_name = att.thumbnail.name
|
||||
att_name = att.attachment.name
|
||||
|
||||
self.assertTrue(default_storage.exists(att_name))
|
||||
self.assertTrue(default_storage.exists(thumb_name))
|
||||
|
||||
self.delete(
|
||||
reverse('api-attachment-detail', kwargs={'pk': att.pk}), expected_code=204
|
||||
)
|
||||
|
||||
self.assertFalse(default_storage.exists(att_name))
|
||||
self.assertFalse(default_storage.exists(thumb_name))
|
||||
|
||||
def test_thumbnail_zero_byte_file(self):
|
||||
"""Uploading a zero-byte file should be rejected by Django's file validation before reaching thumbnail logic."""
|
||||
empty_file = SimpleUploadedFile('empty.png', b'', content_type='image/png')
|
||||
# Django's FileField rejects empty uploads at the serializer/validation layer
|
||||
response = self._upload_attachment(empty_file, expected_code=400)
|
||||
self.assertIn('attachment', response.data)
|
||||
|
||||
def test_thumbnail_link_attachment(self):
|
||||
"""An attachment created with an external link (no file) should not generate a thumbnail."""
|
||||
from common.models import Attachment
|
||||
|
||||
response = self.post(
|
||||
reverse('api-attachment-list'),
|
||||
data={
|
||||
'model_type': 'part',
|
||||
'model_id': self.part.pk,
|
||||
'link': 'https://example.com/some/resource',
|
||||
},
|
||||
format='multipart',
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
def test_is_image_filter(self):
|
||||
"""The is_image filter on the attachment list endpoint should return only matching attachments."""
|
||||
url = reverse('api-attachment-list')
|
||||
base_filters = {'model_type': 'part', 'model_id': self.part.pk}
|
||||
|
||||
# Upload one valid image and three non-image attachments
|
||||
self._upload_attachment(self._make_image_file('img1.png'))
|
||||
self._upload_attachment(
|
||||
SimpleUploadedFile(
|
||||
'corrupt.png', b'not image data', content_type='image/png'
|
||||
)
|
||||
)
|
||||
self._upload_attachment(
|
||||
SimpleUploadedFile('doc.txt', b'hello', content_type='text/plain')
|
||||
)
|
||||
self.post(
|
||||
url,
|
||||
data={**base_filters, 'link': 'https://example.com/resource'},
|
||||
format='multipart',
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
all_attachments = self.get(url, base_filters, expected_code=200).data
|
||||
self.assertEqual(len(all_attachments), 4)
|
||||
|
||||
# is_image=true → only the valid image
|
||||
images = self.get(
|
||||
url, {**base_filters, 'is_image': 'true'}, expected_code=200
|
||||
).data
|
||||
self.assertEqual(len(images), 1)
|
||||
self.assertTrue(images[0]['is_image'])
|
||||
|
||||
# is_image=false → the three non-image attachments
|
||||
non_images = self.get(
|
||||
url, {**base_filters, 'is_image': 'false'}, expected_code=200
|
||||
).data
|
||||
self.assertEqual(len(non_images), 3)
|
||||
self.assertTrue(all(not a['is_image'] for a in non_images))
|
||||
|
||||
def test_upload_exceeds_size_limit(self):
|
||||
"""Uploading a file that exceeds INVENTREE_UPLOAD_MAX_SIZE should be rejected with a 400 error."""
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
|
||||
original_limit = get_global_setting('INVENTREE_UPLOAD_MAX_SIZE')
|
||||
# Use a 1 MB ceiling so the test file stays small and fast
|
||||
set_global_setting('INVENTREE_UPLOAD_MAX_SIZE', 1, change_user=None)
|
||||
|
||||
limit_bytes = 1 * 1024 * 1024
|
||||
|
||||
try:
|
||||
# File exactly at the limit — validator uses >, so this must be accepted
|
||||
self._upload_attachment(
|
||||
SimpleUploadedFile(
|
||||
'at_limit.txt', b'\x00' * limit_bytes, content_type='text/plain'
|
||||
),
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# File one byte over the limit — must be rejected
|
||||
response = self._upload_attachment(
|
||||
SimpleUploadedFile(
|
||||
'over_limit.txt',
|
||||
b'\x00' * (limit_bytes + 1),
|
||||
content_type='text/plain',
|
||||
),
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn('attachment', response.data)
|
||||
finally:
|
||||
set_global_setting(
|
||||
'INVENTREE_UPLOAD_MAX_SIZE', original_limit, change_user=None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import io
|
|||
import os
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def get_legacy_models():
|
||||
|
|
@ -209,6 +211,85 @@ class TestForwardMigrations(MigratorTestCase):
|
|||
self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2)
|
||||
|
||||
|
||||
class TestAttachmentThumbnailMigration(MigratorTestCase):
|
||||
"""Test that migration 0043 correctly populates is_image and generates thumbnails for existing attachments."""
|
||||
|
||||
migrate_from = ('common', '0041_auto_20251203_1244')
|
||||
migrate_to = ('common', '0043_auto_20260518_1206')
|
||||
|
||||
def prepare(self):
|
||||
"""Create a set of attachments with different file types in the pre-migration state.
|
||||
|
||||
At this point the Attachment model has no is_image or thumbnail fields yet.
|
||||
Files are written to storage directly through the FileField so that the
|
||||
data migration can find them at their stored paths.
|
||||
"""
|
||||
Attachment = self.old_state.apps.get_model('common', 'Attachment')
|
||||
|
||||
# 1. Valid PNG image — migration should set is_image=True and create a thumbnail
|
||||
buf = io.BytesIO()
|
||||
Image.new('RGB', (100, 100), color='blue').save(buf, format='PNG')
|
||||
Attachment.objects.create(
|
||||
model_type='part',
|
||||
model_id=1,
|
||||
attachment=ContentFile(buf.getvalue(), name='valid_image.png'),
|
||||
comment='valid_image',
|
||||
)
|
||||
|
||||
# 2. File with a .png extension but non-image content — migration should leave is_image=False
|
||||
Attachment.objects.create(
|
||||
model_type='part',
|
||||
model_id=1,
|
||||
attachment=ContentFile(b'this is not image data', name='corrupt.png'),
|
||||
comment='corrupt_image',
|
||||
)
|
||||
|
||||
# 3. Plain text file — migration should leave is_image=False with no thumbnail
|
||||
Attachment.objects.create(
|
||||
model_type='part',
|
||||
model_id=1,
|
||||
attachment=ContentFile(b'Hello, InvenTree!', name='document.txt'),
|
||||
comment='text_file',
|
||||
)
|
||||
|
||||
# 4. Link attachment (no file at all) — migration should skip it entirely
|
||||
Attachment.objects.create(
|
||||
model_type='part',
|
||||
model_id=1,
|
||||
link='https://example.com/resource',
|
||||
comment='link_attachment',
|
||||
)
|
||||
|
||||
self.assertEqual(Attachment.objects.count(), 4)
|
||||
|
||||
def test_attachment_thumbnails_after_migration(self):
|
||||
"""After applying migrations 0042 and 0043, verify is_image and thumbnail are correct."""
|
||||
Attachment = self.new_state.apps.get_model('common', 'Attachment')
|
||||
|
||||
self.assertEqual(Attachment.objects.count(), 4)
|
||||
|
||||
# Valid image → is_image set, thumbnail file created in storage
|
||||
att = Attachment.objects.get(comment='valid_image')
|
||||
self.assertTrue(att.is_image)
|
||||
self.assertTrue(att.thumbnail)
|
||||
self.assertTrue(default_storage.exists(att.thumbnail.name))
|
||||
|
||||
# Corrupt image → is_image not set, no thumbnail
|
||||
att = Attachment.objects.get(comment='corrupt_image')
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
# Text file → is_image not set, no thumbnail
|
||||
att = Attachment.objects.get(comment='text_file')
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
# Link attachment → is_image not set, no thumbnail
|
||||
att = Attachment.objects.get(comment='link_attachment')
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
|
||||
def prep_currency_migration(self, vals: str):
|
||||
"""Prepare the environment for the currency migration tests."""
|
||||
# Set keys
|
||||
|
|
|
|||
|
|
@ -86,6 +86,11 @@ class AttachmentTest(InvenTreeAPITestCase):
|
|||
}
|
||||
|
||||
for fn, expected in filenames.items():
|
||||
expected_path = f'attachments/part/{part.pk}/{expected}'
|
||||
# Remove the file if it already exists (i.e. from a previous test run)
|
||||
if default_storage.exists(expected_path):
|
||||
default_storage.delete(expected_path)
|
||||
|
||||
attachment = Attachment.objects.create(
|
||||
attachment=self.generate_file(fn),
|
||||
comment=f'Testing filename: {fn}',
|
||||
|
|
@ -93,7 +98,6 @@ class AttachmentTest(InvenTreeAPITestCase):
|
|||
model_id=part.pk,
|
||||
)
|
||||
|
||||
expected_path = f'attachments/part/{part.pk}/{expected}'
|
||||
self.assertEqual(attachment.attachment.name, expected_path)
|
||||
self.assertEqual(attachment.file_size, 15)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import SuspiciousFileOperation, ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.icons
|
||||
|
|
@ -76,6 +77,21 @@ def validate_attachment_model_type(value):
|
|||
raise ValidationError('Model type does not support attachments')
|
||||
|
||||
|
||||
def validate_attachment_file(attachment):
|
||||
"""Ensure that the provided attachment file is valid."""
|
||||
max_size = get_global_setting('INVENTREE_UPLOAD_MAX_SIZE', create=False)
|
||||
|
||||
if attachment.size > (max_size * 1024 * 1024):
|
||||
raise ValidationError(
|
||||
_(f'File size exceeds maximum upload limit of {max_size} MB')
|
||||
)
|
||||
|
||||
try:
|
||||
default_storage.generate_filename(attachment.name)
|
||||
except SuspiciousFileOperation: # pragma: no cover
|
||||
raise ValidationError(_('Invalid file name'))
|
||||
|
||||
|
||||
def validate_notes_model_type(value):
|
||||
"""Ensure that the provided model type is valid.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
"""JSON serializers for Company app."""
|
||||
|
||||
import io
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db.models import Prefetch
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
|
@ -26,7 +23,6 @@ from InvenTree.serializers import (
|
|||
InvenTreeTagModelSerializer,
|
||||
NotesFieldMixin,
|
||||
OptionalField,
|
||||
RemoteImageMixin,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
|
|
@ -113,7 +109,6 @@ class CompanySerializer(
|
|||
FilterableSerializerMixin,
|
||||
DataImportExportSerializerMixin,
|
||||
NotesFieldMixin,
|
||||
RemoteImageMixin,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for Company object (full detail)."""
|
||||
|
|
@ -145,7 +140,6 @@ class CompanySerializer(
|
|||
'notes',
|
||||
'parts_supplied',
|
||||
'parts_manufactured',
|
||||
'remote_image',
|
||||
'primary_address',
|
||||
'tax_id',
|
||||
'parameters',
|
||||
|
|
@ -193,27 +187,6 @@ class CompanySerializer(
|
|||
|
||||
parameters = common.filters.enable_parameters_filter()
|
||||
|
||||
def save(self):
|
||||
"""Save the Company instance."""
|
||||
super().save()
|
||||
|
||||
company = self.instance
|
||||
|
||||
# Check if an image was downloaded from a remote URL
|
||||
remote_img = getattr(self, 'remote_image_file', None)
|
||||
|
||||
if remote_img and company:
|
||||
fmt = remote_img.format or 'PNG'
|
||||
buffer = io.BytesIO()
|
||||
remote_img.save(buffer, format=fmt)
|
||||
|
||||
# Construct a simplified name for the image
|
||||
filename = f'company_{company.pk}_image.{fmt.lower()}'
|
||||
|
||||
company.image.save(filename, ContentFile(buffer.getvalue()))
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
@register_importer()
|
||||
class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ class TestAddressMigration(MigratorTestCase):
|
|||
c1 = Company.objects.filter(name='Company 1').first()
|
||||
c2 = Company.objects.filter(name='Company 2').first()
|
||||
|
||||
self.assertEqual(len(Address.objects.all()), 2)
|
||||
self.assertEqual(Address.objects.count(), 2)
|
||||
|
||||
a1 = Address.objects.filter(company=c1.pk).first()
|
||||
a2 = Address.objects.filter(company=c2.pk).first()
|
||||
|
|
|
|||
|
|
@ -18,15 +18,15 @@
|
|||
# TO MAINTAINERS: Do not change database strings
|
||||
database:
|
||||
# --- Available options: ---
|
||||
# ENGINE: Database engine. Selection from:
|
||||
# engine: Database engine. Selection from:
|
||||
# - mysql
|
||||
# - postgresql
|
||||
# - sqlite3
|
||||
# NAME: Database name
|
||||
# USER: Database username (if required)
|
||||
# PASSWORD: Database password (if required)
|
||||
# HOST: Database host address (if required)
|
||||
# PORT: Database host port (if required)
|
||||
# name: Database name
|
||||
# user: Database username (if required)
|
||||
# password: Database password (if required)
|
||||
# host: Database host address (if required)
|
||||
# port: Database host port (if required)
|
||||
|
||||
# Base URL for the InvenTree server (or use the environment variable INVENTREE_SITE_URL)
|
||||
# site_url: 'http://localhost:8000'
|
||||
|
|
|
|||
|
|
@ -232,8 +232,8 @@ class ApiTests(InvenTreeAPITestCase):
|
|||
"""Test the API endpoint for listing all status models."""
|
||||
response = self.get(reverse('api-status-all'))
|
||||
|
||||
# 10 built-in state classes, plus the added GeneralState class
|
||||
self.assertEqual(len(response.data), 11)
|
||||
# 11 built-in state classes, plus the added GeneralState class
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
# Test the BuildStatus model
|
||||
build_status = response.data['BuildStatus']
|
||||
|
|
@ -273,7 +273,7 @@ class ApiTests(InvenTreeAPITestCase):
|
|||
)
|
||||
response = self.get(reverse('api-status-all'))
|
||||
|
||||
self.assertEqual(len(response.data), 11)
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
stock_status_cstm = response.data['StockStatus']
|
||||
self.assertEqual(stock_status_cstm['status_class'], 'StockStatus')
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class TransitionMethod:
|
|||
result: bool - True if the transition method was successful, False otherwise.
|
||||
|
||||
Raises:
|
||||
ValidationError: Alert the user that the transition failued
|
||||
ValidationError: Alert the user that the transition failed
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
'TransitionMethod.transition must be implemented'
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ class DataImportSession(models.Model):
|
|||
)
|
||||
|
||||
# Create the column mappings
|
||||
DataImportColumnMap.objects.bulk_create(column_mappings)
|
||||
DataImportColumnMap.objects.bulk_create(column_mappings, batch_size=250)
|
||||
|
||||
self.status = DataImportStatusCode.MAPPING.value
|
||||
self.save()
|
||||
|
|
@ -337,7 +337,7 @@ class DataImportSession(models.Model):
|
|||
imported_rows.append(row)
|
||||
|
||||
# Perform database writes as a single operation
|
||||
DataImportRow.objects.bulk_create(imported_rows)
|
||||
DataImportRow.objects.bulk_create(imported_rows, batch_size=250)
|
||||
|
||||
# Mark the import task as "PROCESSING"
|
||||
self.status = DataImportStatusCode.PROCESSING.value
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
|
|||
|
||||
session.extract_columns()
|
||||
|
||||
self.assertEqual(session.column_mappings.count(), 15)
|
||||
self.assertEqual(session.column_mappings.count(), 14)
|
||||
|
||||
# Check some of the field mappings
|
||||
for field, col in [
|
||||
|
|
|
|||