Merge remote-tracking branch 'origin/master' into select_and_focus_qty_in_stock_add_count_remove

This commit is contained in:
Miklos Marton 2026-05-23 21:21:18 +02:00
commit a55ab3fb1e
347 changed files with 109054 additions and 93156 deletions

View File

@ -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: |

View File

@ -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

View File

@ -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@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # 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@658a901452bb54c799643e060733b7afe9121b8d # pin@v4.14.0
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:
@ -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@658a901452bb54c799643e060733b7afe9121b8d # pin@v4.14.0
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:
@ -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:
@ -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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # pin@v3
with:
sarif_file: results.sarif
category: zizmor
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3

View File

@ -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 }}

View File

@ -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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
sarif_file: results.sarif

View File

@ -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

View File

@ -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

View File

@ -9,13 +9,22 @@ 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
- [#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
@ -24,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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

View File

@ -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>

View File

@ -1,3 +1,6 @@
ignore:
- "src/backend/InvenTree/**/test_migrations.py"
coverage:
status:
project:

View File

@ -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
@ -53,9 +53,9 @@ mysqlclient==2.2.8 \
--hash=sha256:9bed7c8d3b629bdc09e17fb628d5b3b0a5fd1f12b09432b464b9126c727bedc0 \
--hash=sha256:a81f5e12f8d05439709cb02fba97f9f76d1a6c528164f2260d8798fec969e300
# via -r contrib/container/requirements.in
packaging==26.1 \
--hash=sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f \
--hash=sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via
# -c src/backend/requirements.txt
# gunicorn
@ -257,7 +257,7 @@ uv==0.11.7 \
--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

View File

@ -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
@ -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

View File

@ -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:

View File

@ -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/).

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -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.

View File

@ -17,3 +17,4 @@ Custom states can be defined for the following models:
- [Purchase Order](../purchasing/purchase_order.md)
- [Sales Order](../sales/sales_order.md)
- [Return Order](../sales/return_order.md)
- [Transfer Order](../stock/transfer_order.md)

View File

@ -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.

View File

@ -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.
@ -192,3 +205,17 @@ When comparing BOMs from two different assemblies, the user can select from the
| *Show common parts* | Display only the BOM line items which are common between the two assemblies. |
In each case, any differences between the BOM line items are highlighted in red.
## Replacing Components
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.
To replace a component part within multiple assemblies:
- 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
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:
{{ image("build/replace_component.png", "Replace Component") }}

View File

@ -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") }}

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -234,3 +234,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")}}

View File

@ -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") }}
@ -206,6 +210,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 +241,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 |

View File

@ -30,6 +30,7 @@ The *Display Settings* screen shows general display configuration options:
{{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}}
{{ usersetting("ENABLE_LAST_BREADCRUMB") }}
{{ usersetting("SHOW_FULL_LOCATION_IN_TABLES") }}
{{ usersetting("DISPLAY_ITEMS_FINAL_LEVEL") }}
### Search Settings

View File

@ -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

View File

@ -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") }}

View File

@ -145,6 +145,7 @@ 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

View File

@ -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.47 \
--hash=sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905 \
--hash=sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd
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
@ -444,9 +444,9 @@ neoteroi-mkdocs==1.2.0 \
--hash=sha256:58e25cb1b9db093ffa8d12bdb33264bf567cac30fb964b56e0a493efa749ad6e \
--hash=sha256:91b6aa95a4e46c9bb93e00e021d2044cb0c7d80c0b4600434ff8f440d613a0a8
# via -r docs/requirements.in
packaging==26.1 \
--hash=sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f \
--hash=sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de
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
@ -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

View File

@ -120,6 +120,7 @@ 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

View File

@ -1,11 +1,48 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 482
INVENTREE_API_VERSION = 493
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v493 -> 2026-05-18 : 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

View File

@ -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()

View File

@ -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:]

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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'.

View File

@ -19,19 +19,25 @@ def get_db_backend():
'ENGINE': get_setting(
'INVENTREE_DB_ENGINE', 'database.engine', '', typecast=str
)
or get_config_value('database.ENGINE'),
or get_config_value('database.ENGINE')
or '',
'NAME': get_setting('INVENTREE_DB_NAME', 'database.name', '', typecast=str)
or get_config_value('database.NAME'),
or get_config_value('database.NAME')
or '',
'USER': get_setting('INVENTREE_DB_USER', 'database.user', '', typecast=str)
or get_config_value('database.USER'),
or get_config_value('database.USER')
or '',
'PASSWORD': get_setting(
'INVENTREE_DB_PASSWORD', 'database.password', '', typecast=str
)
or get_config_value('database.PASSWORD'),
or get_config_value('database.PASSWORD')
or '',
'HOST': get_setting('INVENTREE_DB_HOST', 'database.host', '', typecast=str)
or get_config_value('database.HOST'),
or get_config_value('database.HOST')
or '',
'PORT': get_setting('INVENTREE_DB_PORT', 'database.port', '', typecast=str)
or get_config_value('database.PORT'),
or get_config_value('database.PORT')
or '',
'OPTIONS': get_setting(
'INVENTREE_DB_OPTIONS', 'database.options', {}, typecast=dict
)

View File

@ -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):

View File

@ -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."""

View File

@ -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:

View File

@ -975,10 +975,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."""
@ -1033,7 +1033,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):
@ -1389,7 +1391,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.
@ -1530,7 +1532,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."""
@ -1655,7 +1657,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))

View File

@ -9,6 +9,7 @@ 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
@ -1548,6 +1549,44 @@ 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=201)
# 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)
self.post(url, data={'outputs': [{'output': output_ids[1]}]}, expected_code=201)
# 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."""

View File

@ -932,7 +932,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 +952,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

View File

@ -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."""

View File

@ -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",
),
),
]

View File

@ -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),
]

View File

@ -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',
)

View File

@ -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)

View File

@ -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'),
@ -716,6 +703,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 +825,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 +911,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 +1223,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'),

View File

@ -268,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,
},
}

View File

@ -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.')

View File

@ -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)

View File

@ -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
)

View File

@ -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

View File

@ -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)

View File

@ -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.

View File

@ -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):

View File

@ -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()

View File

@ -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')

View File

@ -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

View File

@ -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 [

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More