Merge branch 'master' into matmair/issue11460

This commit is contained in:
Matthias Mair 2026-04-22 23:34:44 +02:00 committed by GitHub
commit 738aea29ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
329 changed files with 82967 additions and 74907 deletions

View File

@ -185,14 +185,14 @@ jobs:
fi
- name: Login to Dockerhub
if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # pin@v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # pin@v4.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into registry ghcr.io
if: github.event_name != 'pull_request'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # pin@v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # pin@v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@ -202,7 +202,7 @@ jobs:
- name: Export API Documentation
run: invoke dev.schema --ignore-warnings --filename src/backend/InvenTree/schema.yml
- name: Upload schema
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # pin@v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: schema.yml
path: src/backend/InvenTree/schema.yml
@ -222,7 +222,7 @@ jobs:
echo "Downloaded api.yaml"
- name: Running OpenAPI Spec diff action
id: breaking_changes
uses: oasdiff/oasdiff-action/diff@1f38ea5ea0b4a2e4e49901c3bcdf4386a05e9ea1 # pin@main
uses: oasdiff/oasdiff-action/diff@f8cb9308b42121e793f835bd14c0b8090420430c # pin@main
with:
base: "api.yaml"
revision: "src/backend/InvenTree/schema.yml"
@ -251,17 +251,17 @@ jobs:
- name: Extract settings / tags
run: invoke int.export-definitions --basedir docs
- name: Upload settings
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # pin@v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: inventree_settings.json
path: docs/generated/inventree_settings.json
- name: Upload tags
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # pin@v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: inventree_tags.yml
path: docs/generated/inventree_tags.yml
- name: Upload filters
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # pin@v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: inventree_filters.yml
path: docs/generated/inventree_filters.yml
@ -363,7 +363,7 @@ jobs:
pip install .
if: needs.paths-filter.outputs.submit-performance == 'true'
- name: Performance Reporting
uses: CodSpeedHQ/action@1c8ae4843586d3ba879736b7f6b7b0c990757fab # pin@v4
uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # pin@v4
# 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:
@ -409,7 +409,7 @@ jobs:
- name: Coverage Tests
run: invoke dev.test --check --coverage --translations
- name: Upload raw coverage to artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # pin@v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: coverage
path: .coverage
@ -454,7 +454,7 @@ jobs:
env:
node_version: '>=20.19.0'
- name: Performance Reporting
uses: CodSpeedHQ/action@1c8ae4843586d3ba879736b7f6b7b0c990757fab # pin@v4
uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # pin@v4
with:
mode: walltime
run: inv dev.test --pytest
@ -725,7 +725,7 @@ jobs:
invoke static
env INVENTREE_CUSTOM_SPLASH="img/playwright_custom_splash.png" INVENTREE_CUSTOM_LOGO="img/playwright_custom_logo.png" npx nyc playwright test --project=customization
npx nyc playwright test --project=chromium --project=firefox
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # pin@v7.0.0
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report
@ -770,7 +770,7 @@ jobs:
run: |
cd src/backend/InvenTree/web/static
zip -r frontend-build.zip web/ web/.vite
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # pin@v7.0.0
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: frontend-build
path: src/backend/InvenTree/web/static/web

View File

@ -28,7 +28,7 @@ jobs:
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
python3 .github/scripts/version_check.py
- name: Push to Stable Branch
uses: ad-m/github-push-action@77c5b412c50b723d2a4fbc6d71fb5723bcd439aa # pin@v1.0.0
uses: ad-m/github-push-action@4cc74773234f74829a8c21bc4d69dd4be9cfa599 # pin@v1.1.0
if: env.stable_release == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
@ -86,7 +86,7 @@ jobs:
tag: ${{ github.ref }}
overwrite: true
- name: Upload frontend to artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # pin@v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: frontend-build
path: src/backend/InvenTree/web/static/frontend-build.zip

View File

@ -59,7 +59,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: SARIF file
path: results.sarif

View File

@ -97,3 +97,4 @@ repos:
rev: 0.4.3
hooks:
- id: teyit
language_version: python3.11

4
.vscode/launch.json vendored
View File

@ -36,7 +36,9 @@
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
"args": [
"runserver",
"0.0.0.0:8000"
"0.0.0.0:8000",
// "--sync",// Synchronize worker tasks to foreground thread
// "--noreload", // disable auto-reload
],
"django": true,
"justMyCode": false

View File

@ -9,11 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes
### Added
- [#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
### Changed
### Removed
## 1.3.0 - 2026-04-11
### Breaking Changes
- [#11303](https://github.com/inventree/InvenTree/pull/11303) removes the `default_supplier` field from the `Part` model. Instead, the `SupplierPart` model now has a `primary` field which is used to indicate which supplier is the default for a given part. Any external client applications which made use of the old `default_supplier` field will need to be updated.
- [#11500](https://github.com/inventree/InvenTree/pull/11500) fixes a spelling mistake in the database configuration values, which may affect some users running the PostgreSQL database backend. The `tcp_keepalives_internal` option has been renamed to `tcp_keepalives_interval` to reflect the correct PostgreSQL configuration option name. If you are using PostgreSQL, and have set a custom value for the `tcp_keepalives_internal` option, you will need to update this to `tcp_keepalives_interval` in your configuration (either via environment variable or config file).
### Added
- [#11702](https://github.com/inventree/InvenTree/pull/11702) adds "last updated" and "updated by" fields for label and report templates, allowing users to track when a template was last modified and by whom.
- [#11685](https://github.com/inventree/InvenTree/pull/11685) exposes the data importer wizard to the plugin interface, allowing plugins to trigger the data importer wizard and perform custom data imports from the UI.
- [#11692](https://github.com/inventree/InvenTree/pull/11692) adds line item numbering for external orders (purchase, sales and return orders). This allows users to specify a line number for each line item on the order, which can be used for reference purposes. The line number is optional, and can be left blank if not required. The line number is stored as a string, to allow for more flexible formatting (e.g. "1", "1.1", "A", etc).
- [#11641](https://github.com/inventree/InvenTree/pull/11641) adds support for custom parameters against the SalesOrderShipment model.
- [#11527](https://github.com/inventree/InvenTree/pull/11527) adds a new API endpoint for monitoring the status of a particular background task. This endpoint allows clients to check the status of a background task and receive updates when the task is complete. This is useful for long-running tasks that may take some time to complete, allowing clients to provide feedback to users about the progress of the task.
- [#11405](https://github.com/inventree/InvenTree/pull/11405) adds default table filters, which hide inactive items by default. The default table filters are overridden by user filter selection, and only apply to the table view initially presented to the user. This means that users can still view inactive items if they choose to, but they will not be shown by default.

View File

@ -6,9 +6,9 @@ asgiref==3.11.1 \
# via
# -c src/backend/requirements.txt
# django
django==5.2.12 \
--hash=sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7 \
--hash=sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb
django==5.2.13 \
--hash=sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a \
--hash=sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in
@ -17,15 +17,15 @@ django-auth-ldap==5.3.0 \
--hash=sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19 \
--hash=sha256:aa880415983149b072f876d976ef8ec755a438090e176817998263a6ed9e1038
# via -r contrib/container/requirements.in
gunicorn==25.2.0 \
--hash=sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c \
--hash=sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc
gunicorn==25.3.0 \
--hash=sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660 \
--hash=sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in
invoke==2.2.1 \
--hash=sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8 \
--hash=sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707
invoke==3.0.3 \
--hash=sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c \
--hash=sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in

View File

@ -1,4 +1,4 @@
# Packages needed for CI/packages
requests==2.33.0
requests==2.33.1
pyyaml==6.0.3
jc==1.25.6

View File

@ -6,136 +6,136 @@ certifi==2026.2.25 \
# via
# -c src/backend/requirements.txt
# requests
charset-normalizer==3.4.6 \
--hash=sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e \
--hash=sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c \
--hash=sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5 \
--hash=sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815 \
--hash=sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f \
--hash=sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0 \
--hash=sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484 \
--hash=sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407 \
--hash=sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6 \
--hash=sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8 \
--hash=sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264 \
--hash=sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815 \
--hash=sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2 \
--hash=sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4 \
--hash=sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579 \
--hash=sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f \
--hash=sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa \
--hash=sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95 \
--hash=sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab \
--hash=sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297 \
--hash=sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a \
--hash=sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e \
--hash=sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84 \
--hash=sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8 \
--hash=sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0 \
--hash=sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9 \
--hash=sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f \
--hash=sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1 \
--hash=sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843 \
--hash=sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565 \
--hash=sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7 \
--hash=sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c \
--hash=sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b \
--hash=sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7 \
--hash=sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687 \
--hash=sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9 \
--hash=sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14 \
--hash=sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89 \
--hash=sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f \
--hash=sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0 \
--hash=sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9 \
--hash=sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a \
--hash=sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389 \
--hash=sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0 \
--hash=sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30 \
--hash=sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd \
--hash=sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e \
--hash=sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9 \
--hash=sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc \
--hash=sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532 \
--hash=sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d \
--hash=sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae \
--hash=sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2 \
--hash=sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64 \
--hash=sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f \
--hash=sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557 \
--hash=sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e \
--hash=sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff \
--hash=sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398 \
--hash=sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db \
--hash=sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a \
--hash=sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43 \
--hash=sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597 \
--hash=sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c \
--hash=sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e \
--hash=sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2 \
--hash=sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54 \
--hash=sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e \
--hash=sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4 \
--hash=sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4 \
--hash=sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7 \
--hash=sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6 \
--hash=sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5 \
--hash=sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194 \
--hash=sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69 \
--hash=sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f \
--hash=sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316 \
--hash=sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e \
--hash=sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73 \
--hash=sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8 \
--hash=sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923 \
--hash=sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88 \
--hash=sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f \
--hash=sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21 \
--hash=sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4 \
--hash=sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6 \
--hash=sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc \
--hash=sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2 \
--hash=sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866 \
--hash=sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021 \
--hash=sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2 \
--hash=sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d \
--hash=sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8 \
--hash=sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de \
--hash=sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237 \
--hash=sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4 \
--hash=sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778 \
--hash=sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb \
--hash=sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc \
--hash=sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602 \
--hash=sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4 \
--hash=sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f \
--hash=sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5 \
--hash=sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611 \
--hash=sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8 \
--hash=sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf \
--hash=sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d \
--hash=sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b \
--hash=sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db \
--hash=sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e \
--hash=sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077 \
--hash=sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd \
--hash=sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef \
--hash=sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e \
--hash=sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8 \
--hash=sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe \
--hash=sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058 \
--hash=sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17 \
--hash=sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833 \
--hash=sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421 \
--hash=sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550 \
--hash=sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff \
--hash=sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2 \
--hash=sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc \
--hash=sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982 \
--hash=sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d \
--hash=sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed \
--hash=sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104 \
--hash=sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659
charset-normalizer==3.4.7 \
--hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \
--hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \
--hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \
--hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \
--hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \
--hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \
--hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \
--hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \
--hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \
--hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \
--hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \
--hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \
--hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \
--hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \
--hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \
--hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \
--hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \
--hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \
--hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \
--hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \
--hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \
--hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \
--hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \
--hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \
--hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \
--hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \
--hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \
--hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \
--hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \
--hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \
--hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \
--hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \
--hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \
--hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \
--hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \
--hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \
--hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \
--hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \
--hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \
--hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \
--hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \
--hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \
--hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \
--hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \
--hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \
--hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \
--hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \
--hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \
--hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \
--hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \
--hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \
--hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \
--hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \
--hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \
--hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \
--hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \
--hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \
--hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \
--hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \
--hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \
--hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \
--hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \
--hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \
--hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \
--hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \
--hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \
--hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \
--hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \
--hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \
--hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \
--hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \
--hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \
--hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \
--hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \
--hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \
--hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \
--hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \
--hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \
--hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \
--hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \
--hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \
--hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \
--hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \
--hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \
--hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \
--hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \
--hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \
--hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \
--hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \
--hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \
--hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \
--hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \
--hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \
--hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \
--hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \
--hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \
--hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \
--hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \
--hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \
--hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \
--hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \
--hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \
--hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \
--hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \
--hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \
--hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \
--hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \
--hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \
--hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \
--hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \
--hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \
--hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \
--hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \
--hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \
--hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \
--hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \
--hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \
--hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \
--hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \
--hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \
--hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \
--hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \
--hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \
--hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \
--hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \
--hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
# via
# -c src/backend/requirements.txt
# requests
@ -230,9 +230,9 @@ pyyaml==6.0.3 \
# via
# -c src/backend/requirements.txt
# -r contrib/dev_reqs/requirements.in
requests==2.33.0 \
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
requests==2.33.1 \
--hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \
--hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a
# via
# -c src/backend/requirements.txt
# -r contrib/dev_reqs/requirements.in

View File

@ -77,7 +77,7 @@ The *Part Settings* view allows you to configure various options governing what
| Option | Description |
| --- | --- |
| Parameters | Enable display of part parameters in the part detail view |
| Parameters | Enable display of parameters in the part detail view |
| BOM | Enable bill of materials display in the part detail view |
| Stock History | Enable display of stock history in the stock detail view |
| Test Results | Enable display of test results in the stock detail view |

View File

@ -15,7 +15,7 @@ Parameters can be associated with various InvenTree models.
Any model which supports parameters will have a "Parameters" tab on its detail page. This tab displays all parameters associated with that object:
{{ image("concepts/parameter-tab.png", "Part Parameters Example") }}
{{ image("concepts/parameter-tab.png", "Parameters Example") }}
## Parameter Templates
@ -40,9 +40,9 @@ Parameter templates are created and edited via the [admin interface](../settings
To create a template:
- Navigate to the "Settings" page
- Click on the "Part Parameters" tab
- Click on the "Parameters" tab
- Click on the "New Parameter" button
- Fill out the `Create Part Parameter Template` form: `Name` (required) and `Units` (optional) fields
- Fill out the `Create Parameter Template` form: `Name` (required) and `Units` (optional) fields
- Click on the "Submit" button.
An existing template can be edited by clicking on the "Edit" button associated with that template:
@ -53,9 +53,9 @@ An existing template can be edited by clicking on the "Edit" button associated w
After [creating a template](#create-template) or using the existing templates, you can add parameters to any part.
To add a parameter, navigate to a specific part detail page, click on the "Parameters" tab then click on the "New Parameters" button, the `Create Part Parameter` form will be displayed:
To add a parameter, navigate to a specific part detail page, click on the "Parameters" tab then click on the "New Parameters" button, the `Create Parameter` form will be displayed:
{{ image("part/create_part_parameter.png", "Create Part Parameter Form") }}
{{ image("part/create_part_parameter.png", "Create Parameter Form") }}
Select the parameter `Template` you would like to use for this parameter, fill-out the `Data` field (value of this specific parameter) and click the "Submit" button.
@ -132,7 +132,7 @@ The in-built conversion functionality means that parameter values can be input i
### Incompatible Units
If a part parameter is created with a value which is incompatible with the units specified for the template, it will be rejected:
If a parameter is created with a value which is incompatible with the units specified for the template, it will be rejected:
{{ image("part/part_invalid_units.png", "Invalid Parameter Units") }}
@ -151,4 +151,4 @@ Selection Lists can be used to add a large number of predefined values to a para
It is possible that plugins lock selection lists to ensure a known state.
Administration of lists can be done through the Part Parameter section in the [Admin Center](../settings/admin.md#admin-center) or via the API.
Administration of lists can be done through the `Parameter` section in the [Admin Center](../settings/admin.md#admin-center) or via the API.

View File

@ -8,7 +8,7 @@ Support for real-world "physical" units of measure is implemented using the [pin
- Ensures consistent use of real units for your inventory management
- Convert between compatible units of measure from suppliers
- Enforce use of compatible units when creating part parameters
- Enforce use of compatible units when creating parameters
- Enable custom units as required
### Unit Conversion
@ -61,7 +61,7 @@ The [supplier part](../part/index.md/#supplier-parts) model uses real-world unit
### Parameter
The [parameter template](../concepts/parameters.md#parameter-templates) model can specify units of measure, and part parameters can be specified against these templates with compatible units
The [parameter template](../concepts/parameters.md#parameter-templates) model can specify units of measure, and parameters can be specified against these templates with compatible units
## Custom Units

View File

@ -60,7 +60,12 @@ If you only need a superuser, run the `superuser` task. It should prompt you for
#### Run background workers
If you need to process your queue with background workers, run the `worker` task. This is a foreground task which will execute in the terminal.
If you need to process your queue with background workers, open a new terminal and run the `worker` task with `invoke worker`. This is a foreground task which will execute in the terminal.
If you are developing functions that will be executed by background workers there are a two debugging options.
- If the workers are started with the `worker` task you can add `print` or `logger` statements to the code and monitor the output in the terminal.
- All tasks can be forced to run in the foreground worker by uncommenting the `--sync` and `--noreload` arguments under the `InvenTree Server - 3rd party` entry in `.vscode/launch.json`. With this setting you should not start a separate background worker, instead you start the `InvenTree Server - 3rd party` from the `Run and Debug` side panel. All task will now run in one single process and you can set breakpoints, inspect variables and single step also tasks that normally are offloaded to background workers. It should be noted that with this setting the GUI will be unresponsive while tasks are executed.
### Running InvenTree

View File

@ -280,9 +280,27 @@ def on_post_build(*args, **kwargs):
ignored_settings = {
'global': ['SERVER_RESTART_REQUIRED'],
'user': ['LAST_USED_PRINTING_MACHINES'],
'config': [
'INVENTREE_DB_TCP_KEEPALIVES',
'INVENTREE_DB_TCP_KEEPALIVES_IDLE',
'INVENTREE_DB_TCP_KEEPALIVES_INTERVAL',
'INVENTREE_DB_TCP_KEEPALIVES_COUNT',
'INVENTREE_DB_ISOLATION_SERIALIZABLE',
'INVENTREE_DB_WAL_MODE',
'INVENTREE_PLUGIN_DIR',
'INVENTREE_DOCKER',
'INVENTREE_FLAGS',
'INVENTREE_REMOTE_LOGIN',
'INVENTREE_REMOTE_LOGIN_HEADER',
'TEST_TRANSLATIONS',
'INVENTREE_FRONTEND_URL_BASE',
'INVENTREE_FRONTEND_API_HOST',
'INVENTREE_FRONTEND_SETTINGS',
'INVENTREE_LOGOUT_REDIRECT_URL',
],
}
for group in ['global', 'user']:
for group in ['global', 'user', 'config']:
expected = expected_settings.get(group, {})
observed = observed_settings.get(group, {})
ignored = ignored_settings.get(group, [])

View File

@ -20,7 +20,7 @@ New parts can be created manually by selecting the *Create Part* option from the
{{ image("part/part_create_form.png", "New part form") }}
Fill out the required part parameters and then press *Submit* to create the new part. If there are any form errors, you must fix these before the form can be successfully submitted.
Fill out the required attributes and then press *Submit* to create the new part. If there are any form errors, you must fix these before the form can be successfully submitted.
Once the form is completed, the browser window is redirected to the new part detail page.

View File

@ -81,7 +81,7 @@ Parts can be locked to prevent them from being modified. This is useful for part
- Locked parts cannot be deleted
- BOM items cannot be created, edited, or deleted when they are part of a locked assembly
- Part parameters linked to a locked part cannot be created, edited or deleted
- Parameters linked to a locked part cannot be created, edited or deleted
## Active Parts

View File

@ -10,6 +10,9 @@ Plugins can inherit from the [UserInterfaceMixin](./mixins/ui.md) class to provi
Note that the [InvenTree plugin creator](./creator.md) can be used to scaffold a new plugin with the necessary structure for frontend integration. This will automatically set up the necessary files and configurations to get started with frontend development.
!!! info "Simplified Development"
Using the plugin creator can significantly simplify the process of developing a frontend plugin, as it provides a ready-made template with the necessary configurations for building and integrating the frontend code. Rolling your own frontend plugin from scratch is possible, but it requires a good understanding of the InvenTree frontend architecture and build process.
## Frontend Architecture
When designing a frontend plugin component, it is important to have at least a basic understanding of the InvenTree frontend architecture.
@ -76,20 +79,28 @@ The following properties are available in the `context` object:
| Property | Description |
| -------- | ----------- |
| `version` | An object containing the current InvenTree version information. |
| `api` | The Axios instance configured to communicate with the InvenTree API. |
| `queryClient` | The query client instance used for managing API calls in the frontend. |
| `user` | An object containing information about the currently logged-in user. |
| `userSettings` | An object containing user-specific settings. |
| `globalSettings` | An object containing global settings for the InvenTree instance. |
| `modelInformation` | An object containing information about the models available in the InvenTree instance. |
| `renderInstance` | A function to render a model instance |
| `host` | An object containing information about the current host (server) configuration. |
| `i18n` | An object containing internationalization (i18n) functions for translating text. |
| `locale` | The current locale being used for the user interface. |
| `api` | The Axios instance configured to communicate with the InvenTree API. |
| `queryClient` | The query client instance used for managing API calls in the frontend. |
| `navigate` | A function to navigate to a different page in the InvenTree web interface. |
| `globalSettings` | An object containing global settings for the InvenTree instance. |
| `userSettings` | An object containing user-specific settings. |
| `modelInformation` | An object containing information about the models available in the InvenTree instance. |
| `renderInstance` | A function to render a model instance |
| `theme` | The current Mantine theme being used in the InvenTree web interface. |
| `colorScheme` | The current color scheme being used in the InvenTree web interface. |
| `forms` | A set of functional components for rendering forms in the InvenTree web interface. |
| `tables` | A set of functional components for rendering tables in the InvenTree web interface. |
| `importer` | A set of functions for controlling the global importer drawer in the InvenTree web interface. |
| `model` | The model type associated with the rendered component (if applicable). |
| `id` | The ID (primary key) of the model instance being rendered (if applicable). |
| `instance` | The model instance data (if available). |
| `reloadContent` | A function which can be called to reload the plugin content. |
| `reloadInstance` | A function which can be called to reload the model instance. |
| `context` | Any additional context data which may be passed to the plugin. |
This set of components is passed through at render time to the plugin function, allowing the plugin code to hook directly into the InvenTree web interface and access the necessary context for rendering.

View File

@ -74,6 +74,9 @@ Enter the package name into the form as shown below. You can add a path and a ve
{{ image("plugin/plugin_install_txt.png", "Plugin.txt file") }}
!!! info "Superuser Required"
Only users with superuser privileges can manage plugins via the web interface.
#### Local Directory
Custom plugins can be placed in the `data/plugins/` directory, where they will be automatically discovered. This can be useful for developing and testing plugins, but can prove more difficult in production (e.g. when using Docker).

View File

@ -13,6 +13,9 @@ When a certain (server-side) event occurs, the background worker passes the even
{{ image("plugin/enable_events.png", "Enable event integration") }}
!!! info "Worker debugging"
As the events are offloaded to a background worker debugging the `process_event()` function need some extra consideration. Please see the [Run background workers](../../develop/devcontainer.md#run-background-workers) section for further information.
## Events
Events are passed through using a string identifier, e.g. `build.completed`

View File

@ -9,11 +9,10 @@ For complicated plugins it makes sense to add unit tests the code to ensure that
For plugin testing the following environment variables must be set to True:
| Name | Function | Value |
| ---- | -------- | ----- |
| INVENTREE_PLUGINS_ENABLED | Enables the use of 3rd party plugins | True |
| INVENTREE_PLUGIN_TESTING | Enables enables all plugins no matter of their active state in the db or built-in flag | True |
| INVENTREE_PLUGIN_TESTING_SETUP | Enables the url mixin | True |
{{ configtable() }}
{{ configsetting("INVENTREE_PLUGINS_ENABLED") }} Enables the use of 3rd party plugins |
{{ configsetting("INVENTREE_PLUGIN_TESTING") }} Enables enables all plugins no matter of their active state in the db or built-in flag |
{{ configsetting("INVENTREE_PLUGIN_TESTING_SETUP") }} Enables the url mixin |
### Test Program

View File

@ -173,8 +173,144 @@ Generate a list of all active customers:
More advanced database filtering should be achieved using a [report plugin](../plugins/mixins/report.md), and adding custom context data to the report template.
## List Helpers
The following helper functions are available for working with list (or list-like) data structures:
### length
Return the length of a list (or list-like) data structure. Note that this will also work for other data structures which support the `len()` function, such as strings, dictionaries or querysets:
::: report.templatetags.report.length
options:
show_docstring_description: false
show_source: False
### first
Return the first element of a list (or list-like) data structure:
::: report.templatetags.report.first
options:
show_docstring_description: false
show_source: False
### last
Return the last element of a list (or list-like) data structure:
::: report.templatetags.report.last
options:
show_docstring_description: false
show_source: False
### reverse
Return a list (or list-like) data structure in reverse order:
::: report.templatetags.report.reverse
options:
show_docstring_description: false
show_source: False
### truncate
Return a truncated version of a list (or list-like) data structure, containing only the first N elements:
::: report.templatetags.report.truncate
options:
show_docstring_description: false
show_source: False
## String Formatting
### strip
Return a string with leading and trailing whitespace removed:
::: report.templatetags.report.strip
options:
show_docstring_description: false
show_source: False
### lstrip
Return a string with leading whitespace removed:
::: report.templatetags.report.lstrip
options:
show_docstring_description: false
show_source: False
### rstrip
Return a string with trailing whitespace removed:
::: report.templatetags.report.rstrip
options:
show_docstring_description: false
show_source: False
### split
Return a list of substrings by splitting a string based on a specified separator:
::: report.templatetags.report.split
options:
show_docstring_description: false
show_source: False
### join
Return a string by joining a list of strings into a single string, using a specified separator:
::: report.templatetags.report.join
options:
show_docstring_description: false
show_source: False
### replace
Return a string where occurrences of a specified substring are replaced with another substring:
::: report.templatetags.report.replace
options:
show_docstring_description: false
show_source: False
### lowercase
Return a string with all characters converted to lowercase:
::: report.templatetags.report.lowercase
options:
show_docstring_description: false
show_source: False
### uppercase
Return a string with all characters converted to uppercase:
::: report.templatetags.report.uppercase
options:
show_docstring_description: false
show_source: False
### titlecase
Return a string with the first character of each word converted to uppercase and the remaining characters converted to lowercase:
::: report.templatetags.report.titlecase
options:
show_docstring_description: false
show_source: False
## Number Formatting
A number of helper functions are available for formatting numbers in a particular way. These can be used to format numbers according to a particular number of decimal places, or to add leading zeros, for example.
### format_number
The helper function `format_number` allows for some common number formatting options. It takes a number (or a number-like string) as an input, as well as some formatting arguments. It returns a *string* containing the formatted number:
@ -547,14 +683,18 @@ You can add asset images to the reports and labels by using the `{% raw %}{% ass
## Parameters
If you need to load a parameter value for a particular model instance, within the context of your template, you can use the `parameter` template tag:
If you need to reference a parameter for a particular model instance, within the context of your template, you can use the `parameter` template tag:
### parameter
This returns a [Parameter](../concepts/parameters.md) object which contains the value of the parameter, as well as any associated metadata (e.g. units, description, etc).
::: report.templatetags.report.parameter
options:
show_docstring_description: false
show_source: False
### Example
#### Example
The following example assumes that you have a report or label which contains a valid [Part](../part/index.md) instance:
@ -580,6 +720,27 @@ A [Parameter](../concepts/parameters.md) has the following available attributes:
| Units | The *units* of the parameter (e.g. "km") |
| Template | A reference to a [ParameterTemplate](../concepts/parameters.md#parameter-templates) |
### parameter_value
To access just the value of a parameter, use the `parameter_value` template tag:
::: report.templatetags.report.parameter_value
options:
show_docstring_description: false
show_source: False
#### Example
```
{% raw %}
{% load report %}
{% parameter_value part "length" backup_value="3"as length_value %}
Part: {{ part.name }}<br>
Length: {{ length_value }}
{% endraw %}
```
## Rendering Markdown
Some data fields (such as the *Notes* field available on many internal database models) support [markdown formatting](https://en.wikipedia.org/wiki/Markdown). To render markdown content in a custom report, there are template filters made available through the [django-markdownify](https://github.com/erwinmatijsen/django-markdownify) library. This library provides functionality for converting markdown content to HTML representation, allowing it to be then rendered to PDF by the InvenTree report generation pipeline.

View File

@ -29,10 +29,10 @@ The first step is to ensure that the required provider modules are installed, vi
There are two variables in the configuration file which define the operation of SSO:
| Environment Variable |Configuration File | Description | More Info |
| --- | --- | --- | --- |
| INVENTREE_SOCIAL_BACKENDS | `social_backends` | A *list* of provider backends enabled for the InvenTree instance | [django-allauth docs](https://docs.allauth.org/en/latest/installation/quickstart.html) |
| INVENTREE_SOCIAL_PROVIDERS | `social_providers` | A *dict* of settings specific to the installed providers | [provider documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) |
{{ configtable() }}
{{ configsetting("INVENTREE_SOCIAL_BACKENDS") }} A *list* of [social provider backends](https://docs.allauth.org/en/latest/installation/quickstart.html) enabled for the InvenTree instance |
{{ configsetting("INVENTREE_SOCIAL_PROVIDERS") }} A *dict* of settings specific to the [installed providers](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) |
In the example below, SSO provider modules are activated for *google*, *github* and *microsoft*. Specific configuration options are specified for the *microsoft* provider module:

View File

@ -216,6 +216,11 @@ This warning will not prevent you from restoring the backup but it is recommende
A user is logged in with elevated privileges. This might be a superuser or a administrator user. These types of users have elevated permissions and should not be used for regular usage.
Use separate accounts for administrative tasks and regular usage to reduce risk. Make sure to review the [permission documentation](../settings/permissions.md#dangerous-user-flags).
#### INVE-W15
**Process interrupted by user - Backend**
A process was interrupted by the user, likely by a keyboard interrupt. This might lead to issues with the process that was interrupted, as it might not have completed its task. This is especially relevant for processes that are not idempotent or that do not have a good rollback mechanism.
### INVE-I (InvenTree Information)
Information — These are not errors but information messages. They might point out potential issues or just provide information.

View File

@ -27,6 +27,8 @@ Configuration of basic server settings:
{{ globalsetting("INVENTREE_INSTANCE_TITLE") }}
{{ globalsetting("INVENTREE_INSTANCE_ID", default="Randomly generated value") }}
{{ globalsetting("INVENTREE_ANNOUNCE_ID") }}
{{ globalsetting("INVENTREE_SHOW_SUPERUSER_BANNER") }}
{{ globalsetting("INVENTREE_SHOW_ADMIN_BANNER") }}
{{ globalsetting("INVENTREE_RESTRICT_ABOUT") }}
{{ globalsetting("DISPLAY_FULL_NAMES") }}
{{ globalsetting("DISPLAY_PROFILE_INFO") }}

View File

@ -51,46 +51,44 @@ You can link your InvenTree server to an LDAP server.
Next you can start configuring the connection. Either use the config file or set the environment variables.
| config key | ENV Variable | Description |
| --- | --- | --- |
| `ldap.enabled` | `INVENTREE_LDAP_ENABLED` | Set this to `True` to enable LDAP. |
| `ldap.debug` | `INVENTREE_LDAP_DEBUG` | Set this to `True` to activate debug mode, useful for troubleshooting ldap configurations. |
| `ldap.server_uri` | `INVENTREE_LDAP_SERVER_URI` | LDAP Server URI, e.g. `ldaps://example.org` |
| `ldap.start_tls` | `INVENTREE_LDAP_START_TLS` | Enable TLS encryption over the standard LDAP port, [see](https://django-auth-ldap.readthedocs.io/en/latest/reference.html#auth-ldap-start-tls). (You can set TLS options via `ldap.global_options`) |
| `ldap.bind_dn` | `INVENTREE_LDAP_BIND_DN` | LDAP bind dn, e.g. `cn=admin,dc=example,dc=org` |
| `ldap.bind_password` | `INVENTREE_LDAP_BIND_PASSWORD` | LDAP bind password |
| `ldap.search_base_dn` | `INVENTREE_LDAP_SEARCH_BASE_DN` | LDAP search base dn, e.g. `cn=Users,dc=example,dc=org` |
| `ldap.user_dn_template` | `INVENTREE_LDAP_USER_DN_TEMPLATE` | use direct bind as auth user, `ldap.bind_dn` and `ldap.bin_password` is not necessary then, e.g. `uid=%(user)s,dc=example,dc=org` |
| `ldap.global_options` | `INVENTREE_LDAP_GLOBAL_OPTIONS` | set advanced options as dict, e.g. TLS settings. For a list of all available options, see [python-ldap docs](https://www.python-ldap.org/en/latest/reference/ldap.html#ldap-options). (keys and values starting with OPT_ get automatically converted to `python-ldap` keys) |
| `ldap.search_filter_str`| `INVENTREE_LDAP_SEARCH_FILTER_STR` | LDAP search filter str, default: `uid=%(user)s` |
| `ldap.user_attr_map` | `INVENTREE_LDAP_USER_ATTR_MAP` | LDAP <-> InvenTree user attribute map, can be json if used as env, in yml directly specify the object. default: `{"first_name": "givenName", "last_name": "sn", "email": "mail"}` |
| `ldap.always_update_user` | `INVENTREE_LDAP_ALWAYS_UPDATE_USER` | Always update the user on each login, default: `true` |
| `ldap.cache_timeout` | `INVENTREE_LDAP_CACHE_TIMEOUT` | cache timeout to reduce traffic with LDAP server, default: `3600` (1h) |
| `ldap.group_search` | `INVENTREE_LDAP_GROUP_SEARCH` | Base LDAP DN for group searching; required to enable group features |
| `ldap.group_object_class` | `INVENTREE_LDAP_GROUP_OBJECT_CLASS` | The string to pass to the LDAP group search `(objectClass=<...>)`, default: `groupOfUniqueNames` |
| `ldap.mirror_groups` | `INVENTREE_LDAP_MIRROR_GROUPS` | If `True`, mirror a user's LDAP group membership in the Django database, default: `False` |
| `ldap.group_type_class` | `INVENTREE_LDAP_GROUP_TYPE_CLASS` | The group class to be imported from `django_auth_ldap.config` as a string, default: `'GroupOfUniqueNamesType'`|
| `ldap.group_type_class_args` | `INVENTREE_LDAP_GROUP_TYPE_CLASS_ARGS` | A `list` of positional args to pass to the LDAP group type class, default `[]` |
| `ldap.group_type_class_kwargs` | `INVENTREE_LDAP_GROUP_TYPE_CLASS_KWARGS` | A `dict` of keyword args to pass to the LDAP group type class, default `{'name_attr': 'cn'}` |
| `ldap.require_group` | `INVENTREE_LDAP_REQUIRE_GROUP` | If set, users _must_ be in this group to log in to InvenTree |
| `ldap.deny_group` | `INVENTREE_LDAP_DENY_GROUP` | If set, users _must not_ be in this group to log in to InvenTree |
| `ldap.user_flags_by_group` | `INVENTREE_LDAP_USER_FLAGS_BY_GROUP` | LDAP group to InvenTree user flag map, can be json if used as env, in yml directly specify the object. See config template for example, default: `{}` |
{{ configtable() }}
{{ configsetting("INVENTREE_LDAP_ENABLED") }} Enable LDAP support |
{{ configsetting("INVENTREE_LDAP_DEBUG") }} Set this to `True` to activate debug mode, useful for troubleshooting ldap configurations. |
| `INVENTREE_LDAP_SERVER_URI` | `ldap.server_uri` | LDAP Server URI, e.g. `ldaps://example.org` |
| `INVENTREE_LDAP_START_TLS` | `ldap.start_tls` | Enable TLS encryption over the standard LDAP port, [see](https://django-auth-ldap.readthedocs.io/en/latest/reference.html#auth-ldap-start-tls). (You can set TLS options via `ldap.global_options`) |
| `INVENTREE_LDAP_BIND_DN` | `ldap.bind_dn` | LDAP bind dn, e.g. `cn=admin,dc=example,dc=org` |
| `INVENTREE_LDAP_BIND_PASSWORD` | `ldap.bind_password` | LDAP bind password |
| `INVENTREE_LDAP_SEARCH_BASE_DN` | `ldap.search_base_dn` | LDAP search base dn, e.g. `cn=Users,dc=example,dc=org` |
| `INVENTREE_LDAP_USER_DN_TEMPLATE` | `ldap.user_dn_template` | use direct bind as auth user, `ldap.bind_dn` and `ldap.bin_password` is not necessary then, e.g. `uid=%(user)s,dc=example,dc=org` |
| `INVENTREE_LDAP_GLOBAL_OPTIONS` | `ldap.global_options` | set advanced options as dict, e.g. TLS settings. For a list of all available options, see [python-ldap docs](https://www.python-ldap.org/en/latest/reference/ldap.html#ldap-options). (keys and values starting with OPT_ get automatically converted to `python-ldap` keys) |
| `INVENTREE_LDAP_SEARCH_FILTER_STR`| `ldap.search_filter_str` | LDAP search filter str, default: `uid=%(user)s` |
| `INVENTREE_LDAP_USER_ATTR_MAP` | `ldap.user_attr_map` | LDAP <-> InvenTree user attribute map, can be json if used as env, in yml directly specify the object. default: `{"first_name": "givenName", "last_name": "sn", "email": "mail"}` |
| `INVENTREE_LDAP_ALWAYS_UPDATE_USER` | `ldap.always_update_user` | Always update the user on each login, default: `true` |
| `INVENTREE_LDAP_CACHE_TIMEOUT` | `ldap.cache_timeout` | cache timeout to reduce traffic with LDAP server, default: `3600` (1h) |
| `INVENTREE_LDAP_GROUP_SEARCH` | `ldap.group_search` | Base LDAP DN for group searching; required to enable group features |
| `INVENTREE_LDAP_GROUP_OBJECT_CLASS` | `ldap.group_object_class` | The string to pass to the LDAP group search `(objectClass=<...>)`, default: `groupOfUniqueNames` |
| `INVENTREE_LDAP_MIRROR_GROUPS` | `ldap.mirror_groups` | If `True`, mirror a user's LDAP group membership in the Django database, default: `False` |
| `INVENTREE_LDAP_GROUP_TYPE_CLASS` | `ldap.group_type_class` | The group class to be imported from `django_auth_ldap.config` as a string, default: `'GroupOfUniqueNamesType'`|
| `INVENTREE_LDAP_GROUP_TYPE_CLASS_ARGS` | `ldap.group_type_class_args` | A `list` of positional args to pass to the LDAP group type class, default `[]` |
| `INVENTREE_LDAP_GROUP_TYPE_CLASS_KWARGS` | `ldap.group_type_class_kwargs` | A `dict` of keyword args to pass to the LDAP group type class, default `{'name_attr': 'cn'}` |
| `INVENTREE_LDAP_REQUIRE_GROUP` | `ldap.require_group` | If set, users _must_ be in this group to log in to InvenTree |
| `INVENTREE_LDAP_DENY_GROUP` | `ldap.deny_group` | If set, users _must not_ be in this group to log in to InvenTree |
| `INVENTREE_LDAP_USER_FLAGS_BY_GROUP` | `ldap.user_flags_by_group` | LDAP group to InvenTree user flag map, can be json if used as env, in yml directly specify the object. See config template for example, default: `{}` |
## Tracing support
Starting with 0.14.0 InvenTree supports sending traces, logs and metrics to OpenTelemetry compatible endpoints (both HTTP and gRPC). A [list of vendors](https://opentelemetry.io/ecosystem/vendors) is available on the project site.
This can be used to track usage and performance of the InvenTree backend and connected services like databases, caches and more.
| config key | ENV Variable | Description |
| --- | --- | --- |
| `tracing.enabled` | `INVENTREE_TRACING_ENABLED` | Set this to `True` to enable OpenTelemetry. |
| `tracing.endpoint` | `INVENTREE_TRACING_ENDPOINT` | General endpoint for information (not specific trace/log url) |
| `tracing.headers` | `INVENTREE_TRACING_HEADERS` | HTTP headers that should be send with every request (often used for authentication). Format as a dict. |
| `tracing.auth.basic` | `INVENTREE_TRACING_AUTH_BASIC` | Auth headers that should be send with every requests (will be encoded to b64 and overwrite auth headers) |
| `tracing.is_http` | `INVENTREE_TRACING_IS_HTTP` | Are the endpoints HTTP (True, default) or gRPC (False) |
| `tracing.append_http` | `INVENTREE_TRACING_APPEND_HTTP` | Append default url routes (v1) to `tracing.endpoint` |
| `tracing.console` | `INVENTREE_TRACING_CONSOLE` | Print out all exports (additionally) to the console for debugging. Do not use in production |
| `tracing.resources` | `INVENTREE_TRACING_RESOURCES` | Add additional resources to all exports. This can be used to add custom tags to the traces. Format as a dict. |
{{ configtable() }}
{{ configsetting("INVENTREE_TRACING_ENABLED") }} Enable OpenTelemetry |
{{ configsetting("INVENTREE_TRACING_ENDPOINT") }} General endpoint for information (not specific trace/log url) |
{{ configsetting("INVENTREE_TRACING_HEADERS") }} HTTP headers that should be send with every request (often used for authentication). Format as a dict. |
{{ configsetting("INVENTREE_TRACING_AUTH") }} Auth headers that should be send with every requests (will be encoded to b64 and overwrite auth headers) |
{{ configsetting("INVENTREE_TRACING_IS_HTTP") }} Are the endpoints HTTP (True, default) or gRPC (False) |
{{ configsetting("INVENTREE_TRACING_APPEND_HTTP") }} Append default url routes (v1) to `tracing.endpoint` |
{{ configsetting("INVENTREE_TRACING_CONSOLE") }} Print out all exports (additionally) to the console for debugging. Do not use in production |
{{ configsetting("INVENTREE_TRACING_RESOURCES") }} Add additional resources to all exports. This can be used to add custom tags to the traces. Format as a dict. |
## Multi Site Support
@ -99,7 +97,6 @@ If your InvenTree instance is used in a multi-site environment, you can enable m
!!! tip "Django Documentation"
For more information on multi-site support, refer to the [Django documentation]({% include "django.html" %}/ref/contrib/sites/).
| Environment Variable | Config Key | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_SITE_MULTI | site_multi | Enable multiple sites | False |
| INVENTREE_SITE_ID | site_id | Specify a fixed site ID | _Not specified_ |
{{ configtable() }}
{{ configsetting("INVENTREE_SITE_MULTI") }} Enable multiple sites |
{{ configsetting("INVENTREE_SITE_ID") }} Specify a fixed site ID |

View File

@ -17,19 +17,19 @@ The django-dbbackup library provides [multiple configuration options](https://ar
The following configuration options are available for backup:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_BACKUP_STORAGE | backup_storage | Backup storage backend. Refer to the [storage backend documentation](#storage-backend). | django.core.files.storage.FileSystemStorage |
| INVENTREE_BACKUP_DIR | backup_dir | Backup storage directory. | *No default* |
| INVENTREE_BACKUP_OPTIONS | backup_options | Specific options for the selected storage backend (dict) | *No default* |
| INVENTREE_BACKUP_CONNECTOR_OPTIONS | backup_connector_options | Specific options for the database connector (dict). Refer to the [database connector options](#database-connector). | *No default* |
| INVENTREE_BACKUP_SEND_EMAIL | backup_send_email | If True, an email is sent to the site admin when an error occurs during a backup or restore procedure. | False |
| INVENTREE_BACKUP_EMAIL_PREFIX | backup_email_prefix | Prefix for the subject line of backup-related emails. | `[InvenTree Backup]` |
| INVENTREE_BACKUP_GPG_RECIPIENT | backup_gpg_recipient | Specify GPG recipient if using encryption for backups. | *No default* |
| INVENTREE_BACKUP_DATE_FORMAT | backup_date_format | Date format string used to format timestamps in backup filenames. | `%Y-%m-%d-%H%M%S` |
| INVENTREE_BACKUP_DATABASE_FILENAME_TEMPLATE | backup_database_filename_template | Template string used to generate database backup filenames. | `InvenTree-db-{datetime}.{extension}` |
| INVENTREE_BACKUP_MEDIA_FILENAME_TEMPLATE | backup_media_filename_template | Template string used to generate media backup filenames. | `InvenTree-media-{datetime}.{extension}` |
| INVENTREE_BACKUP_RESTORE_ALLOW_NEWER_VERSION | backup_restore_allow_newer_version | If True, allows restoring a backup created with a newer version of InvenTree. This is dangerous as it can lead to hard-to-debug data loss. | False |
{{ configtable() }}
{{ configsetting("INVENTREE_BACKUP_STORAGE") }} Backup storage backend. Refer to the [storage backend documentation](#storage-backend). |
{{ configsetting("INVENTREE_BACKUP_DIR") }} Backup storage directory. |
{{ configsetting("INVENTREE_BACKUP_OPTIONS") }} Specific options for the selected storage backend (dict) |
{{ configsetting("INVENTREE_BACKUP_CONNECTOR_OPTIONS") }} Specific options for the database connector (dict). Refer to the [database connector options](#database-connector) |
{{ configsetting("INVENTREE_BACKUP_SEND_EMAIL") }} If True, an email is sent to the site admin when an error occurs during a backup or restore procedure. |
{{ configsetting("INVENTREE_BACKUP_EMAIL_PREFIX") }} Prefix for the subject line of backup-related emails. |
{{ configsetting("INVENTREE_BACKUP_GPG_RECIPIENT") }} Specify GPG recipient if using encryption for backups. |
{{ configsetting("INVENTREE_BACKUP_DATE_FORMAT") }} Date format string used to format timestamps in backup filenames. |
{{ configsetting("INVENTREE_BACKUP_DATABASE_FILENAME_TEMPLATE") }} Template string used to generate database backup filenames.
{{ configsetting("INVENTREE_BACKUP_MEDIA_FILENAME_TEMPLATE") }} Template string used to generate media backup filenames. |
{{ configsetting("INVENTREE_BACKUP_RESTORE_ALLOW_NEWER_VERSION") }} If True, allows restoring a backup created with a newer version of InvenTree. This is dangerous as it can lead to hard-to-debug data loss. |
### Storage Backend

View File

@ -46,6 +46,9 @@ Environment variable settings generally use the `INVENTREE_` prefix, and are all
!!! warning "Available Variables"
Some configuration options cannot be set via environment variables. Refer to the documentation below.
!!! info "Environment Variable Priority"
Note that a provided environment variable will override the value provided in the configuration file.
#### List Values
To specify a list value in an environment variable, use a comma-separated list. For example, to specify a list of trusted origins:
@ -58,14 +61,13 @@ INVENTREE_TRUSTED_ORIGINS='https://inventree.example.com:8443,https://stock.exam
The following basic options are available:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_SITE_URL | site_url | Specify a fixed site URL | *Not specified* |
| INVENTREE_TIMEZONE | timezone | Server timezone | UTC |
| INVENTREE_ADMIN_ENABLED | admin_enabled | Enable the [django administrator interface]({% include "django.html" %}/ref/contrib/admin/) | True |
| INVENTREE_ADMIN_URL | admin_url | URL for accessing [admin interface](../settings/admin.md) | admin |
| INVENTREE_LANGUAGE | language | Default language | en-us |
| INVENTREE_AUTO_UPDATE | auto_update | Database migrations will be run automatically | False |
{{ configtable() }}
{{ configsetting("INVENTREE_SITE_URL") }} Specify a fixed site URL |
{{ configsetting("INVENTREE_TIMEZONE") }} Server timezone |
{{ configsetting("INVENTREE_ADMIN_ENABLED") }} Enable the [django administrator interface]({% include "django.html" %}/ref/contrib/admin/) |
{{ configsetting("INVENTREE_ADMIN_URL") }} URL for accessing [admin interface](../settings/admin.md) |
{{ configsetting("INVENTREE_LANGUAGE") }} Default language |
{{ configsetting("INVENTREE_AUTO_UPDATE") }} Database migrations will be run automatically |
### Site URL
@ -89,18 +91,17 @@ With "auto update" enabled, the InvenTree server will automatically apply databa
The following debugging / logging options are available:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_DEBUG | debug | Enable [debug mode](./index.md#debug-mode) | False |
| INVENTREE_DEBUG_QUERYCOUNT | debug_querycount | Enable support for [django-querycount](../develop/index.md#django-querycount) middleware. | False |
| INVENTREE_DEBUG_SILK | debug_silk | Enable support for [django-silk](../develop/index.md#django-silk) profiling tool. | False |
| INVENTREE_DEBUG_SILK_PROFILING | debug_silk_profiling | Enable detailed profiling in django-silk | False |
| INVENTREE_DB_LOGGING | db_logging | Enable logging of database messages | False |
| INVENTREE_LOG_LEVEL | log_level | Set level of logging to terminal | WARNING |
| INVENTREE_JSON_LOG | json_log | log as json | False |
| INVENTREE_WRITE_LOG | write_log | Enable writing of log messages to file at config base | False |
| INVENTREE_CONSOLE_LOG | console_log | Enable logging to console | True |
| INVENTREE_SCHEMA_LEVEL | schema.level | Set level of added schema extensions detail (0-3) 0 = including no additional detail | 0 |
{{ configtable() }}
{{ configsetting("INVENTREE_DEBUG") }} Enable [debug mode](./index.md#debug-mode) |
{{ configsetting("INVENTREE_DB_LOGGING") }} Enable logging of database messages |
{{ configsetting("INVENTREE_LOG_LEVEL") }} Set level of logging to terminal |
{{ configsetting("INVENTREE_JSON_LOG") }} Log messages as json |
{{ configsetting("INVENTREE_WRITE_LOG") }} Enable writing of log messages to file at config base |
{{ configsetting("INVENTREE_CONSOLE_LOG") }} Enable logging to console |
{{ configsetting("INVENTREE_SCHEMA_LEVEL") }} Set level of added schema extensions detail (0-3) 0 = including no additional detail |
{{ configsetting("INVENTREE_DEBUG_QUERYCOUNT") }} Enable support for [django-querycount](../develop/index.md#django-querycount) middleware. |
{{ configsetting("INVENTREE_DEBUG_SILK") }} Enable support for [django-silk](../develop/index.md#django-silk) profiling tool. |
| `INVENTREE_DEBUG_SILK_PROFILING` | `debug_silk_profiling` | False | Enable detailed profiling in django-silk |
### Debug Mode
@ -118,26 +119,24 @@ Depending on how your InvenTree installation is configured, you will need to pay
!!! danger "Not Secure"
Allowing access from any host is not secure, and should be adjusted for your installation.
!!! info "Environment Variables"
Note that a provided environment variable will override the value provided in the configuration file.
!!! success "INVENTREE_SITE_URL"
If you have specified the `INVENTREE_SITE_URL`, this will automatically be used as a trusted CSRF and CORS host (see below).
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_ALLOWED_HOSTS | allowed_hosts | List of allowed hosts | `*` |
| INVENTREE_TRUSTED_ORIGINS | trusted_origins | List of trusted origins. Refer to the [django documentation]({% include "django.html" %}/ref/settings/#csrf-trusted-origins) | Uses the *INVENTREE_SITE_URL* parameter, if set. Otherwise, an empty list. |
| INVENTREE_CORS_ORIGIN_ALLOW_ALL | cors.allow_all | Allow all remote URLS for CORS checks | `False` |
| INVENTREE_CORS_ORIGIN_WHITELIST | cors.whitelist | List of whitelisted CORS URLs. Refer to the [django-cors-headers documentation](https://github.com/adamchainz/django-cors-headers#cors_allowed_origins-sequencestr) | Uses the *INVENTREE_SITE_URL* parameter, if set. Otherwise, an empty list. |
| INVENTREE_CORS_ORIGIN_REGEX | cors.regex | List of regular expressions for CORS whitelisted URL patterns | *Empty list* |
| INVENTREE_CORS_ALLOW_CREDENTIALS | cors.allow_credentials | Allow cookies in cross-site requests | `True` |
| INVENTREE_SITE_LAX_PROTOCOL | site_lax_protocol | Ignore protocol mismatches on INVE-E7 site checks | `True` |
| INVENTREE_USE_X_FORWARDED_HOST | use_x_forwarded_host | Use forwarded host header | `False` |
| INVENTREE_USE_X_FORWARDED_PORT | use_x_forwarded_port | Use forwarded port header | `False` |
| INVENTREE_USE_X_FORWARDED_PROTO | use_x_forwarded_proto | Use forwarded protocol header | `False` |
| INVENTREE_SESSION_COOKIE_SECURE | cookie.secure | Enforce secure session cookies | `False` |
| INVENTREE_COOKIE_SAMESITE | cookie.samesite | Session cookie mode. Must be one of `Strict | Lax | None | False`. Refer to the [mozilla developer docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) and the [django documentation]({% include "django.html" %}/ref/settings/#std-setting-SESSION_COOKIE_SAMESITE) for more information. | False |
{{ configtable() }}
{{ configsetting("INVENTREE_ALLOWED_HOSTS") }} List of allowed hosts |
{{ configsetting("INVENTREE_TRUSTED_ORIGINS", default="Uses the *INVENTREE_SITE_URL* parameter, if set. Otherwise, an empty list.") }} List of trusted origins. Refer to the [django documentation]({% include "django.html" %}/ref/settings/#csrf-trusted-origins) |
{{ configsetting("INVENTREE_CORS_ORIGIN_ALLOW_ALL") }} Allow all remote URLS for CORS checks |
{{ configsetting("INVENTREE_CORS_ORIGIN_WHITELIST", default="Uses the *INVENTREE_SITE_URL* parameter, if set. Otherwise, an empty list.") }} List of whitelisted CORS URLs. Refer to the [django-cors-headers documentation](https://github.com/adamchainz/django-cors-headers#cors_allowed_origins-sequencestr) |
{{ configsetting("INVENTREE_CORS_ORIGIN_REGEX") }} List of regular expressions for CORS whitelisted URL patterns |
{{ configsetting("INVENTREE_CORS_ALLOW_CREDENTIALS") }} Allow cookies in cross-site requests |
{{ configsetting("INVENTREE_SITE_LAX_PROTOCOL") }} Ignore protocol mismatches on INVE-E7 site checks |
{{ configsetting("INVENTREE_USE_X_FORWARDED_HOST") }} Use forwarded host header |
{{ configsetting("INVENTREE_USE_X_FORWARDED_PORT") }} Use forwarded port header |
{{ configsetting("INVENTREE_USE_X_FORWARDED_PROTO") }} Use forwarded protocol header |
| `INVENTREE_X_FORWARDED_PROTO_NAME` | `x_forwarded_proto_name` | `HTTP_X_FORWARDED_PROTO` | Name of the header to use for forwarded protocol information |
{{ configsetting("INVENTREE_SESSION_COOKIE_SECURE") }} Enforce secure session cookies |
{{ configsetting("INVENTREE_COOKIE_SAMESITE") }} Session cookie mode. Must be one of `Strict | Lax | None | False`. Refer to the [mozilla developer docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) and the [django documentation]({% include "django.html" %}/ref/settings/#std-setting-SESSION_COOKIE_SAMESITE) for more information. |
### Debug Mode
@ -191,10 +190,9 @@ Django provides a powerful [administrator interface]({% include "django.html" %}
The following admin site configuration options are available:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_ADMIN_ENABLED | admin_enabled | Enable the django administrator interface | True |
| INVENTREE_ADMIN_URL | admin_url | URL for accessing the admin interface | admin |
{{ configtable() }}
{{ configsetting("INVENTREE_ADMIN_ENABLED") }} Enable the django administrator interface |
{{ configsetting("INVENTREE_ADMIN_URL") }} URL for accessing the admin interface |
!!! warning "Security"
Changing the admin URL is a simple way to improve security, but it is not a substitute for proper security practices.
@ -203,12 +201,11 @@ The following admin site configuration options are available:
An administrator account can be specified using the following environment variables:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_ADMIN_USER | admin_user | Admin account username | *Not specified* |
| INVENTREE_ADMIN_PASSWORD | admin_password | Admin account password | *Not specified* |
| INVENTREE_ADMIN_PASSWORD_FILE | admin_password_file | Admin account password file | *Not specified* |
| INVENTREE_ADMIN_EMAIL | admin_email |Admin account email address | *Not specified* |
{{ configtable() }}
{{ configsetting("INVENTREE_ADMIN_USER") }} Admin account username |
{{ configsetting("INVENTREE_ADMIN_PASSWORD") }} Admin account password |
{{ configsetting("INVENTREE_ADMIN_PASSWORD_FILE") }} Admin account password file |
{{ configsetting("INVENTREE_ADMIN_EMAIL") }} Admin account email address |
You can either specify the password directly using `INVENTREE_ADMIN_PASSWORD`, or you can specify a file containing the password using `INVENTREE_ADMIN_PASSWORD_FILE` (this is useful for nix users).
@ -238,12 +235,11 @@ A PEM-encoded file containing the oidc private key can be passed via the environ
If not specified via environment variables, the fallback files (automatically generated as part of InvenTree installation) will be used.
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_SECRET_KEY | secret_key | Raw secret key value | *Not specified* |
| INVENTREE_SECRET_KEY_FILE | secret_key_file | File containing secret key value | *Not specified* |
| INVENTREE_OIDC_PRIVATE_KEY | oidc_private_key | Raw private key value | *Not specified* |
| INVENTREE_OIDC_PRIVATE_KEY_FILE | oidc_private_key_file | File containing private key value in PEM format | *Not specified* |
{{ configtable() }}
{{ configsetting("INVENTREE_SECRET_KEY") }} Raw secret key value |
{{ configsetting("INVENTREE_SECRET_KEY_FILE") }} File containing secret key value |
{{ configsetting("INVENTREE_OIDC_PRIVATE_KEY") }} Raw private key value |
{{ configsetting("INVENTREE_OIDC_PRIVATE_KEY_FILE", default="oidc.pem") }} File containing private key value in PEM format |
## Database Options
@ -253,14 +249,14 @@ Database options are specified under the *database* heading in the configuration
The following database options can be configured:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_DB_ENGINE | database.ENGINE | Database backend | *Not specified* |
| INVENTREE_DB_NAME | database.NAME | Database name | *Not specified* |
| INVENTREE_DB_USER | database.USER | Database username (if required) | *Not specified* |
| INVENTREE_DB_PASSWORD | database.PASSWORD | Database password (if required) | *Not specified* |
| INVENTREE_DB_HOST | database.HOST | Database host address (if required) | *Not specified* |
| INVENTREE_DB_PORT | database.PORT | Database host port (if required) | *Not specified* |
{{ configtable() }}
{{ configsetting("INVENTREE_DB_ENGINE") }} Database backend |
{{ configsetting("INVENTREE_DB_NAME") }} Database name |
{{ configsetting("INVENTREE_DB_USER") }} Database username (if required) |
{{ configsetting("INVENTREE_DB_PASSWORD") }} Database password (if required) |
{{ configsetting("INVENTREE_DB_HOST") }} Database host address (if required) |
{{ configsetting("INVENTREE_DB_PORT") }} Database host port (if required) |
{{ configsetting("INVENTREE_DB_OPTIONS") }} Additional database options (as a JSON object) |
!!! tip "Database Password"
The value specified for `INVENTREE_DB_PASSWORD` should not contain comma `,` or colon `:` characters, otherwise the connection to the database may fail.
@ -269,22 +265,20 @@ The following database options can be configured:
If running with a PostgreSQL database backend, the following additional options are available:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_DB_TIMEOUT | database.timeout | Database connection timeout (s) | 2 |
| INVENTREE_DB_TCP_KEEPALIVES | database.tcp_keepalives | TCP keepalive | 1 |
| INVENTREE_DB_TCP_KEEPALIVES_IDLE | database.tcp_keepalives_idle | Idle TCP keepalive | 1 |
| INVENTREE_DB_TCP_KEEPALIVES_INTERVAL | database.tcp_keepalives_interval | TCP keepalive interval | 1|
| INVENTREE_DB_TCP_KEEPALIVES_COUNT | database.tcp_keepalives_count | TCP keepalive count | 5 |
| INVENTREE_DB_ISOLATION_SERIALIZABLE | database.serializable | Database isolation level configured to "serializable" | False |
{{ configtable() }}
{{ configsetting("INVENTREE_DB_TIMEOUT", default="2") }} Database connection timeout (s) |
| `INVENTREE_DB_TCP_KEEPALIVES` | database.tcp_keepalives | 1 | TCP keepalive |
| `INVENTREE_DB_TCP_KEEPALIVES_IDLE` | database.tcp_keepalives_idle | 1 | Idle TCP keepalive |
| `INVENTREE_DB_TCP_KEEPALIVES_INTERVAL` | database.tcp_keepalives_interval | 1| TCP keepalive interval |
| `INVENTREE_DB_TCP_KEEPALIVES_COUNT` | database.tcp_keepalives_count | 5 | TCP keepalive count |
| `INVENTREE_DB_ISOLATION_SERIALIZABLE` | database.serializable | False | Database isolation level configured to "serializable" |
### MySQL Settings
If running with a MySQL database backend, the following additional options are available:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_DB_ISOLATION_SERIALIZABLE | database.serializable | Database isolation level configured to "serializable" | False |
{{ configtable() }}
| `INVENTREE_DB_ISOLATION_SERIALIZABLE` | database.serializable | False | Database isolation level configured to "serializable" |
### SQLite Settings
@ -293,10 +287,9 @@ If running with a MySQL database backend, the following additional options are a
If running with a SQLite database backend, the following additional options are available:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_DB_TIMEOUT | database.timeout | Database connection timeout (s) | 10 |
| INVENTREE_DB_WAL_MODE | database.wal_mode | Enable Write-Ahead Logging (WAL) mode for SQLite databases | True |
{{ configtable() }}
{{ configsetting("INVENTREE_DB_TIMEOUT", default="10") }} Database connection timeout (s) |
| `INVENTREE_DB_WAL_MODE` | database.wal_mode | True | Enable Write-Ahead Logging (WAL) mode for SQLite databases |
## Caching
@ -314,20 +307,21 @@ Enabling global caching requires connection to a redis server (which is separate
The following cache settings are available:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_CACHE_ENABLED | cache.enabled | Enable redis caching | False |
| INVENTREE_CACHE_HOST | cache.host | Cache server host | *Not specified* |
| INVENTREE_CACHE_PORT | cache.port | Cache server port | 6379 |
| INVENTREE_CACHE_PASSWORD | cache.password | Cache server password | none |
| INVENTREE_CACHE_USER | cache.user | Cache server username | none |
| INVENTREE_CACHE_CONNECT_TIMEOUT | cache.connect_timeout | Cache connection timeout (seconds) | 3 |
| INVENTREE_CACHE_TIMEOUT | cache.timeout | Cache timeout (seconds) | 3 |
| INVENTREE_CACHE_TCP_KEEPALIVE | cache.tcp_keepalive | Cache TCP keepalive | True |
| INVENTREE_CACHE_KEEPALIVE_COUNT | cache.keepalive_count | Cache keepalive count | 5 |
| INVENTREE_CACHE_KEEPALIVE_IDLE | cache.keepalive_idle | Cache keepalive idle | 1 |
| INVENTREE_CACHE_KEEPALIVE_INTERVAL | cache.keepalive_interval | Cache keepalive interval | 1 |
| INVENTREE_CACHE_USER_TIMEOUT | cache.user_timeout | Cache user timeout | 1000 |
{{ configtable() }}
{{ configsetting("INVENTREE_CACHE_ENABLED") }} Enable redis caching |
{{ configsetting("INVENTREE_CACHE_HOST") }} Cache server host |
{{ configsetting("INVENTREE_CACHE_PORT") }} Cache server port |
{{ configsetting("INVENTREE_CACHE_PASSWORD") }} Cache server password |
{{ configsetting("INVENTREE_CACHE_USER") }} Cache server username |
{{ configsetting("INVENTREE_CACHE_DB") }} Cache server database index |
{{ configsetting("INVENTREE_CACHE_CONNECT_TIMEOUT") }} Cache connection timeout (seconds) |
{{ configsetting("INVENTREE_CACHE_TIMEOUT") }} Cache timeout (seconds) |
{{ configsetting("INVENTREE_CACHE_TCP_KEEPALIVE") }} Cache TCP keepalive |
{{ configsetting("INVENTREE_CACHE_KEEPALIVE_COUNT") }} Cache keepalive count |
{{ configsetting("INVENTREE_CACHE_KEEPALIVE_IDLE") }} Cache keepalive idle |
{{ configsetting("INVENTREE_CACHE_KEEPALIVE_INTERVAL") }} Cache keepalive interval |
{{ configsetting("INVENTREE_CACHE_USER_TIMEOUT") }} Cache user timeout |
!!! tip "Cache Password"
The value specified for `INVENTREE_CACHE_PASSWORD` should not contain comma `,` or colon `:` characters, otherwise the connection to the cache server may fail.
@ -338,17 +332,16 @@ To enable [email functionality](../settings/email.md), email settings must be co
The following email settings are available:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_EMAIL_BACKEND | email.backend | Email backend module | django.core.mail.backends.smtp.EmailBackend |
| INVENTREE_EMAIL_HOST | email.host | Email server host | *Not specified* |
| INVENTREE_EMAIL_PORT | email.port | Email server port | 25 |
| INVENTREE_EMAIL_USERNAME | email.username | Email account username | *Not specified* |
| INVENTREE_EMAIL_PASSWORD | email.password | Email account password | *Not specified* |
| INVENTREE_EMAIL_TLS | email.tls | Enable STARTTLS support (commonly port 567) | False |
| INVENTREE_EMAIL_SSL | email.ssl | Enable legacy SSL/TLS support (commonly port 465) | False |
| INVENTREE_EMAIL_SENDER | email.sender | Sending email address | *Not specified* |
| INVENTREE_EMAIL_PREFIX | email.prefix | Prefix for subject text | [InvenTree] |
{{ configtable() }}
{{ configsetting("INVENTREE_EMAIL_BACKEND") }} Email backend module |
{{ configsetting("INVENTREE_EMAIL_HOST") }} Email server host |
{{ configsetting("INVENTREE_EMAIL_PORT") }} Email server port |
{{ configsetting("INVENTREE_EMAIL_USERNAME") }} Email account username |
{{ configsetting("INVENTREE_EMAIL_PASSWORD") }} Email account password |
{{ configsetting("INVENTREE_EMAIL_TLS") }} Enable STARTTLS support (commonly port 567) |
{{ configsetting("INVENTREE_EMAIL_SSL") }} Enable legacy SSL/TLS support (commonly port 465) |
{{ configsetting("INVENTREE_EMAIL_SENDER") }} Sending email address |
{{ configsetting("INVENTREE_EMAIL_PREFIX") }} Prefix for subject text |
### Email Backend
@ -365,11 +358,10 @@ The "sender" email address is the address from which InvenTree emails are sent (
InvenTree requires some external directories for storing files:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_STATIC_ROOT | static_root | [Static files](./processes.md#static-files) directory | *Not specified* |
| INVENTREE_MEDIA_ROOT | media_root | [Media files](./processes.md#media-files) directory | *Not specified* |
| INVENTREE_BACKUP_DIR | backup_dir | Backup files directory | *Not specified* |
{{ configtable() }}
{{ configsetting("INVENTREE_STATIC_ROOT") }} [Static files](./processes.md#static-files) directory |
{{ configsetting("INVENTREE_MEDIA_ROOT") }} [Media files](./processes.md#media-files) directory |
{{ configsetting("INVENTREE_BACKUP_DIR") }} Directory for backup files |
!!! tip "Serving Files"
Read the [proxy server documentation](./processes.md#proxy-server) for more information on hosting *static* and *media* files
@ -403,43 +395,41 @@ Alternatively this location can be specified with the `INVENTREE_BACKUP_DIR` env
It is also possible to use alternative storage backends for static and media files, at the moment there is direct provide direct support bundled for S3 and SFTP. Google cloud storage and Azure blob storage would also be supported by the [used library](https://django-storages.readthedocs.io), but require additional packages to be installed.
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_STORAGE_TARGET | storage.target | Storage target to use for static and media files, valid options: local, s3, sftp | local |
{{ configtable() }}
{{ configsetting("INVENTREE_STORAGE_TARGET") }} Storage target to use for static and media files, valid options: local, s3, sftp |
#### S3
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_S3_ACCESS_KEY | storage.s3.access_key | Access key | *Not specified* |
| INVENTREE_S3_SECRET_KEY | storage.s3.secret_key | Secret key | *Not specified* |
| INVENTREE_S3_BUCKET_NAME | storage.s3.bucket_name | Bucket name, required by most providers | *Not specified* |
| INVENTREE_S3_REGION_NAME | storage.s3.region_name | S3 region name | *Not specified* |
| INVENTREE_S3_ENDPOINT_URL | storage.s3.endpoint_url | Custom S3 endpoint URL, defaults to AWS endpoints if not set | *Not specified* |
| INVENTREE_S3_LOCATION | storage.s3.location | Sub-Location that should be used | inventree-server |
| INVENTREE_S3_DEFAULT_ACL | storage.s3.default_acl | Default ACL for uploaded files, defaults to provider default if not set | *Not specified* |
| INVENTREE_S3_VERIFY_SSL | storage.s3.verify_ssl | Verify SSL certificate for S3 endpoint | True |
| INVENTREE_S3_OVERWRITE | storage.s3.overwrite | Overwrite existing files in S3 bucket | False |
| INVENTREE_S3_VIRTUAL | storage.s3.virtual | Use virtual addressing style - by default False -> `path` style, `virtual` style if True | False |
{{ configtable() }}
| `INVENTREE_S3_ACCESS_KEY` | storage.s3.access_key | *Not specified* | Access key |
| `INVENTREE_S3_SECRET_KEY` | storage.s3.secret_key | *Not specified* | Secret key |
| `INVENTREE_S3_BUCKET_NAME` | storage.s3.bucket_name | *Not specified* | Bucket name, required by most providers |
| `INVENTREE_S3_REGION_NAME` | storage.s3.region_name | *Not specified* | S3 region name |
| `INVENTREE_S3_ENDPOINT_URL` | storage.s3.endpoint_url | *Not specified* | Custom S3 endpoint URL, defaults to AWS endpoints if not set |
| `INVENTREE_S3_LOCATION` | storage.s3.location | inventree-server | Sub-Location that should be used |
| `INVENTREE_S3_DEFAULT_ACL` | storage.s3.default_acl | *Not specified* | Default ACL for uploaded files, defaults to provider default if not set |
| `INVENTREE_S3_VERIFY_SSL` | storage.s3.verify_ssl | True | Verify SSL certificate for S3 endpoint |
| `INVENTREE_S3_OVERWRITE` | storage.s3.overwrite | False | Overwrite existing files in S3 bucket |
| `INVENTREE_S3_VIRTUAL` | storage.s3.virtual | False | Use virtual addressing style - by default False -> `path` style, `virtual` style if True |
#### SFTP
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_SFTP_HOST | storage.sftp.host | SFTP host | *Not specified* |
| INVENTREE_SFTP_PARAMS | storage.sftp.params | SFTP connection parameters, see https://docs.paramiko.org/en/latest/api/client.html#paramiko.client.SSHClient.connect; e.g. `{'port': 22, 'user': 'usr', 'password': 'pwd'}` | *Not specified* |
| INVENTREE_SFTP_UID | storage.sftp.uid | SFTP user ID - not required | *Not specified* |
| INVENTREE_SFTP_GID | storage.sftp.gid | SFTP group ID - not required | *Not specified* |
| INVENTREE_SFTP_LOCATION | storage.sftp.location | Sub-Location that should be used | inventree-server |
{{ configtable() }}
| `INVENTREE_SFTP_HOST` | storage.sftp.host | *Not specified* | SFTP host |
| `INVENTREE_SFTP_PARAMS` | storage.sftp.params | *Not specified* | SFTP connection parameters, see https://docs.paramiko.org/en/latest/api/client.html#paramiko.client.SSHClient.connect; e.g. `{'port': 22, 'user': 'usr', 'password': 'pwd'}` |
| `INVENTREE_SFTP_UID` | storage.sftp.uid | *Not specified* | SFTP user ID - not required |
| `INVENTREE_SFTP_GID` | storage.sftp.gid | *Not specified* | SFTP group ID - not required |
| `INVENTREE_SFTP_LOCATION` | storage.sftp.location | inventree-server | Sub-Location that should be used |
## Authentication
InvenTree provides allowance for additional sign-in options. The following options are not enabled by default, and care must be taken by the system administrator when configuring these settings.
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_MFA_ENABLED | mfa_enabled | Enable or disable multi-factor authentication support for the InvenTree server | True |
| INVENTREE_MFA_SUPPORTED_TYPES | mfa_supported_types | List of supported multi-factor authentication types | recovery_codes,totp,webauthn |
{{ configtable() }}
{{ configsetting("INVENTREE_MFA_ENABLED") }} Enable multi-factor authentication support for the InvenTree server |
{{ configsetting("INVENTREE_MFA_SUPPORTED_TYPES") }} List of supported multi-factor authentication types |
{{ configsetting("INVENTREE_USE_JWT") }} Enable support for JSON Web Tokens (JWT) for authentication |
### Single Sign On
@ -454,11 +444,10 @@ There are multiple configuration parameters which must be specified (either in y
The login-experience can be altered with the following settings:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_LOGIN_CONFIRM_DAYS | login_confirm_days | Duration for which confirmation links are valid | 3 |
| INVENTREE_LOGIN_ATTEMPTS | login_attempts | Count of allowed login attempts before blocking user | 5 |
| INVENTREE_LOGIN_DEFAULT_HTTP_PROTOCOL | login_default_protocol | Default protocol to use for login callbacks (e.g. using [SSO](#single-sign-on)) | Uses the protocol specified in `INVENTREE_SITE_URL`, or defaults to *http* |
{{ configtable() }}
{{ configsetting("INVENTREE_LOGIN_CONFIRM_DAYS") }} Duration for which confirmation links are valid |
{{ configsetting("INVENTREE_LOGIN_ATTEMPTS") }} Count of allowed login attempts before blocking user |
{{ configsetting("INVENTREE_LOGIN_DEFAULT_HTTP_PROTOCOL", default="Uses the protocol specified in `INVENTREE_SITE_URL`, or defaults to *http*") }} Default protocol to use for login callbacks (e.g. using [SSO](#single-sign-on)) |
!!! tip "Default Protocol"
If you have specified `INVENTREE_SITE_URL`, the default protocol will be used from that setting. Otherwise, the default protocol will be *http*.
@ -471,22 +460,20 @@ Custom authentication backends can be used by specifying them here. These can fo
The following options are available for configuring the InvenTree [background worker process](./processes.md#background-worker):
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_BACKGROUND_WORKERS | background.workers | Number of background worker processes | 1 |
| INVENTREE_BACKGROUND_TIMEOUT | background.timeout | Timeout for background worker tasks (seconds) | 90 |
| INVENTREE_BACKGROUND_RETRY | background.retry | Time to wait before retrying a background task (seconds) | 300 |
| INVENTREE_BACKGROUND_MAX_ATTEMPTS | background.max_attempts | Maximum number of attempts for a background task | 5 |
{{ configtable() }}
{{ configsetting("INVENTREE_BACKGROUND_WORKERS") }} Number of background worker processes |
{{ configsetting("INVENTREE_BACKGROUND_TIMEOUT") }} Timeout for background worker tasks (seconds) |
{{ configsetting("INVENTREE_BACKGROUND_RETRY") }} Time to wait before retrying a background task (seconds) |
{{ configsetting("INVENTREE_BACKGROUND_MAX_ATTEMPTS") }} Maximum number of attempts for a background task |
## Sentry Integration
The InvenTree server can be integrated with the [sentry.io](https://sentry.io) monitoring service, for error logging and performance tracking.
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_SENTRY_ENABLED | sentry_enabled | Enable sentry.io integration | False |
| INVENTREE_SENTRY_DSN | sentry_dsn | Sentry DSN (data source name) key | *Defaults to InvenTree developer key* |
| INVENTREE_SENTRY_SAMPLE_RATE | sentry_sample_rate | How often to send data samples | 0.1 |
{{ configtable() }}
{{ configsetting("INVENTREE_SENTRY_ENABLED") }} Enable sentry.io integration |
{{ configsetting("INVENTREE_SENTRY_DSN", default="Defaults to InvenTree developer key") }} Sentry DSN (data source name) key |
{{ configsetting("INVENTREE_SENTRY_SAMPLE_RATE") }} How often to send data samples (seconds) |
!!! info "Default DSN"
If enabled with the default DSN, server errors will be logged to a sentry.io account monitored by the InvenTree developers.
@ -495,13 +482,11 @@ The InvenTree server can be integrated with the [sentry.io](https://sentry.io) m
The logo and custom messages can be changed/set:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_CUSTOM_LOGO | customize.logo | Path to custom logo in the static files directory | *Not specified* |
| INVENTREE_CUSTOM_SPLASH | customize.splash | Path to custom splash screen in the static files directory | *Not specified* |
| INVENTREE_CUSTOMIZE | customize.site_header | Custom site header in the Django admin | InvenTree Admin |
| INVENTREE_CUSTOMIZE | customize.login_message | Custom message for login page | *Not specified* |
| INVENTREE_CUSTOMIZE | customize.navbar_message | Custom message for navbar | *Not specified* |
{{ configtable() }}
{{ configsetting("INVENTREE_CUSTOM_LOGO") }} Path to custom logo in the static files directory |
{{ configsetting("INVENTREE_CUSTOM_SPLASH") }} Path to custom splash screen in the static files directory |
{{ configsetting("INVENTREE_SITE_HEADER") }} Custom header text for the django admin page |
{{ configsetting("INVENTREE_CUSTOMIZE") }} JSON object containing custom messages for the login page, navbar, and Django admin site |
The INVENTREE_CUSTOMIZE environment variable must contain a json object with the keys from the table above and
the wanted values. Example:
@ -535,7 +520,6 @@ Set the `INVENTREE_FRONTEND_SETTINGS` Environment variable to a JSON object or u
| `url_compatibility` | Support compatibility with "legacy" URLs? | `true` |
| `sentry_dsn` | Set a Sentry DSN url | *Not specified* |
| `mobile_mode` | Controls if InvenTree web UI can be used by mobile devices. There are 3 options: `default` - does not allow mobile devices; `allow-ignore` - shows a mobile device detected banner with a button to ignore this warning AT THE USERS OWN RISK; `allow-always` - skips the mobile check and allows mobile devices always (of course at the server admins OWN RISK) | `default` |
| `dangerous_hide_evelevated_alert` | Hides the elevated permissions alert in the UI. This is a dangerous option as using the UI with elevated permissions is against the threat model. | `false` |
E.g. to allow mobile devices to ignore the mobile check, use the following Environment variable:
@ -547,15 +531,15 @@ INVENTREE_FRONTEND_SETTINGS='{"mobile_mode": "allow-ignore"}'
The following [plugin](../plugins/index.md) configuration options are available:
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_PLUGINS_ENABLED | plugins_enabled | Enable plugin support | False |
| INVENTREE_PLUGIN_NOINSTALL | plugin_noinstall | Disable Plugin installation via API - only use plugins.txt file | False |
| INVENTREE_PLUGIN_FILE | plugins_plugin_file | Location of plugin installation file | *Not specified* |
| INVENTREE_PLUGIN_DIR | plugins_plugin_dir | Location of external plugin directory | *Not specified* |
| INVENTREE_PLUGINS_MANDATORY | plugins_mandatory | List of [plugins which are considered mandatory](../plugins/index.md#mandatory-third-party-plugins) | *Not specified* |
| INVENTREE_PLUGIN_DEV_SLUG | plugin_dev.slug | Specify plugin to run in [development mode](../plugins/creator.md#backend-configuration) | *Not specified* |
| INVENTREE_PLUGIN_DEV_HOST | plugin_dev.host | Specify host for development mode plugin | http://localhost:5174 |
{{ configtable() }}
{{ configsetting("INVENTREE_PLUGINS_ENABLED") }} Enable plugin support |
{{ configsetting("INVENTREE_PLUGIN_NOINSTALL") }} Disable Plugin installation via API |
{{ configsetting("INVENTREE_PLUGIN_FILE") }} Location of plugin installation file |
| `INVENTREE_PLUGIN_DIR` | `plugin_dir` | *Not specified* | Location of external plugin directory |
{{ configsetting("INVENTREE_PLUGIN_RETRY") }} Number of tries to attempt loading a plugin before giving up |
{{ configsetting("INVENTREE_PLUGINS_MANDATORY") }} List of [plugins which are considered mandatory](../plugins/index.md#mandatory-third-party-plugins) |
{{ configsetting("INVENTREE_PLUGIN_DEV_SLUG") }} Specify plugin to run in [development mode](../plugins/creator.md#backend-configuration) |
{{ configsetting("INVENTREE_PLUGIN_DEV_HOST") }} Specify host for development mode plugin |
## Override Global Settings
@ -563,6 +547,12 @@ If required, [global settings values](../settings/global.md#override-global-sett
To override global settings, provide a "dictionary" of settings overrides in the configuration file, or via an environment variable.
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_GLOBAL_SETTINGS | global_settings | JSON object containing global settings overrides | *Not specified* |
{{ configtable() }}
{{ configsetting("INVENTREE_GLOBAL_SETTINGS") }} JSON object containing global settings overrides |
## Other Settings
Other available settings, not categorized above, are detailed in the table below:
{{ configtable() }}
{{ configsetting("INVENTREE_EXTRA_URL_SCHEMES") }} Allow additional URL schemes for URL validation |

View File

@ -87,13 +87,7 @@ If you are creating the initial database, you need to create an admin (superuser
docker compose run inventree-server invoke superuser
```
Alternatively, admin account details can be specified in the `.env` file, removing the need for this manual step:
| Variable | Description |
| --- | --- |
| INVENTREE_ADMIN_USER | Admin account username |
| INVENTREE_ADMIN_PASSWORD | Admin account password |
| INVENTREE_ADMIN_EMAIL | Admin account email address |
Alternatively, admin account details can be specified using environment variables, or in the `.env` file, removing the need for this manual step. Refer to the [configuration documentation](./config.md#administrator-account) for more information.
!!! warning "Scrub Account Data"
Ensure that the admin account credentials are removed from the `.env` file after the first run, for security.
@ -241,9 +235,9 @@ By default, the Dockerized InvenTree web server binds to all available network i
This can be adjusted using the following environment variables:
| Environment Variable | Default |
| --- | --- | --- | --- |
| INVENTREE_WEB_ADDR | 0.0.0.0 |
| INVENTREE_WEB_PORT | 8000 |
| --- | --- |
| `INVENTREE_WEB_ADDR` | 0.0.0.0 |
| `INVENTREE_WEB_PORT` | 8000 |
These variables are combined in the [Dockerfile]({{ sourcefile("contrib/container/Dockerfile") }}) to build the bind string passed to the InvenTree server on startup.

View File

@ -34,6 +34,7 @@ for key in [
print(f' - {key}: {val}')
# Cached settings dict values
global CONFIG_SETTINGS
global GLOBAL_SETTINGS
global USER_SETTINGS
global TAGS
@ -64,6 +65,7 @@ with open(settings_file, encoding='utf-8') as sf:
GLOBAL_SETTINGS = settings['global']
USER_SETTINGS = settings['user']
CONFIG_SETTINGS = settings['config']
# Tags
with open(gen_base.joinpath('inventree_tags.yml'), encoding='utf-8') as f:
@ -377,6 +379,34 @@ def define_env(env):
return rendersetting(key, setting, short=short)
@env.macro
def configtable():
"""Generate a header for the configuration settings table."""
return '| Environment Variable | Configuration File | Default | Description |\n| --- | --- | --- | --- |'
@env.macro
def configsetting(key: str, default: Optional[str] = None):
"""Extract information on a particular configuration setting.
Arguments:
key: The name of the configuration setting to extract information for.
default: An optional default value to override the setting's default display value.
"""
global CONFIG_SETTINGS
setting = CONFIG_SETTINGS[key]
observe_setting(key, 'config')
cfg_key = setting.get('config_key', None)
cfg_key = f'`{cfg_key}`' if cfg_key else '-'
default = default or setting.get('default_value', None)
if default is None:
default = '*Not Specified*'
return f'| <span title="{key}" style="white-space: nowrap;"><code>{key}</code></span> | {cfg_key} | {default} |'
@env.macro
def tags_and_filters():
"""Return a list of all tags and filters."""

View File

@ -36,145 +36,146 @@ certifi==2026.2.25 \
# httpcore
# httpx
# requests
charset-normalizer==3.4.6 \
--hash=sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e \
--hash=sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c \
--hash=sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5 \
--hash=sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815 \
--hash=sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f \
--hash=sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0 \
--hash=sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484 \
--hash=sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407 \
--hash=sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6 \
--hash=sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8 \
--hash=sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264 \
--hash=sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815 \
--hash=sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2 \
--hash=sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4 \
--hash=sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579 \
--hash=sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f \
--hash=sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa \
--hash=sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95 \
--hash=sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab \
--hash=sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297 \
--hash=sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a \
--hash=sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e \
--hash=sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84 \
--hash=sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8 \
--hash=sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0 \
--hash=sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9 \
--hash=sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f \
--hash=sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1 \
--hash=sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843 \
--hash=sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565 \
--hash=sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7 \
--hash=sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c \
--hash=sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b \
--hash=sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7 \
--hash=sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687 \
--hash=sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9 \
--hash=sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14 \
--hash=sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89 \
--hash=sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f \
--hash=sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0 \
--hash=sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9 \
--hash=sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a \
--hash=sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389 \
--hash=sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0 \
--hash=sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30 \
--hash=sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd \
--hash=sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e \
--hash=sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9 \
--hash=sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc \
--hash=sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532 \
--hash=sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d \
--hash=sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae \
--hash=sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2 \
--hash=sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64 \
--hash=sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f \
--hash=sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557 \
--hash=sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e \
--hash=sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff \
--hash=sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398 \
--hash=sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db \
--hash=sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a \
--hash=sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43 \
--hash=sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597 \
--hash=sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c \
--hash=sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e \
--hash=sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2 \
--hash=sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54 \
--hash=sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e \
--hash=sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4 \
--hash=sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4 \
--hash=sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7 \
--hash=sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6 \
--hash=sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5 \
--hash=sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194 \
--hash=sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69 \
--hash=sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f \
--hash=sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316 \
--hash=sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e \
--hash=sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73 \
--hash=sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8 \
--hash=sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923 \
--hash=sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88 \
--hash=sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f \
--hash=sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21 \
--hash=sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4 \
--hash=sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6 \
--hash=sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc \
--hash=sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2 \
--hash=sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866 \
--hash=sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021 \
--hash=sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2 \
--hash=sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d \
--hash=sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8 \
--hash=sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de \
--hash=sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237 \
--hash=sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4 \
--hash=sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778 \
--hash=sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb \
--hash=sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc \
--hash=sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602 \
--hash=sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4 \
--hash=sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f \
--hash=sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5 \
--hash=sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611 \
--hash=sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8 \
--hash=sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf \
--hash=sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d \
--hash=sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b \
--hash=sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db \
--hash=sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e \
--hash=sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077 \
--hash=sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd \
--hash=sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef \
--hash=sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e \
--hash=sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8 \
--hash=sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe \
--hash=sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058 \
--hash=sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17 \
--hash=sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833 \
--hash=sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421 \
--hash=sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550 \
--hash=sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff \
--hash=sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2 \
--hash=sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc \
--hash=sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982 \
--hash=sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d \
--hash=sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed \
--hash=sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104 \
--hash=sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659
charset-normalizer==3.4.7 \
--hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \
--hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \
--hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \
--hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \
--hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \
--hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \
--hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \
--hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \
--hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \
--hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \
--hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \
--hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \
--hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \
--hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \
--hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \
--hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \
--hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \
--hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \
--hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \
--hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \
--hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \
--hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \
--hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \
--hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \
--hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \
--hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \
--hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \
--hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \
--hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \
--hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \
--hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \
--hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \
--hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \
--hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \
--hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \
--hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \
--hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \
--hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \
--hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \
--hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \
--hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \
--hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \
--hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \
--hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \
--hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \
--hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \
--hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \
--hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \
--hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \
--hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \
--hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \
--hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \
--hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \
--hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \
--hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \
--hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \
--hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \
--hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \
--hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \
--hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \
--hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \
--hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \
--hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \
--hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \
--hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \
--hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \
--hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \
--hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \
--hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \
--hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \
--hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \
--hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \
--hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \
--hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \
--hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \
--hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \
--hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \
--hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \
--hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \
--hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \
--hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \
--hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \
--hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \
--hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \
--hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \
--hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \
--hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \
--hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \
--hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \
--hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \
--hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \
--hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \
--hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \
--hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \
--hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \
--hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \
--hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \
--hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \
--hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \
--hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \
--hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \
--hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \
--hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \
--hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \
--hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \
--hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \
--hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \
--hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \
--hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \
--hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \
--hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \
--hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \
--hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \
--hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \
--hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \
--hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \
--hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \
--hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \
--hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \
--hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \
--hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \
--hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \
--hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \
--hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \
--hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \
--hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
# via
# -c src/backend/requirements.txt
# requests
click==8.3.1 \
--hash=sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a \
--hash=sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6
click==8.3.2 \
--hash=sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5 \
--hash=sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d
# via
# mkdocs
# neoteroi-mkdocs
# properdocs
colorama==0.4.6 \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
@ -194,7 +195,9 @@ essentials-openapi==1.4.0 \
ghp-import==2.1.0 \
--hash=sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619 \
--hash=sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343
# via mkdocs
# via
# mkdocs
# properdocs
gitdb==4.0.12 \
--hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \
--hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf
@ -242,6 +245,7 @@ jinja2==3.1.6 \
# mkdocs-material
# mkdocstrings
# neoteroi-mkdocs
# properdocs
jsbeautifier==1.15.4 \
--hash=sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592 \
--hash=sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528
@ -255,6 +259,7 @@ markdown==3.10.2 \
# mkdocs-autorefs
# mkdocs-material
# mkdocstrings
# properdocs
# pymdown-extensions
markdown-it-py==4.0.0 \
--hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \
@ -357,6 +362,7 @@ markupsafe==3.0.3 \
# mkdocs
# mkdocs-autorefs
# mkdocstrings
# properdocs
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
@ -396,9 +402,9 @@ mkdocs-git-revision-date-localized-plugin==1.5.1 \
--hash=sha256:2b0239455cd84784dd87ac8dfc9253fe4b2dd35e102696f21b5d34e2175981c6 \
--hash=sha256:b00fd36ed0f9b2326b1488fd8fa31bf2ce64e68c4aa60a9ce857f10719571903
# via -r docs/requirements.in
mkdocs-include-markdown-plugin==7.2.1 \
--hash=sha256:30da634c568ea5d5f9e5881d51f80ac30d8c5f891cec160344ad7a0fdaea6286 \
--hash=sha256:5d94db87b06cd303619dbaebba5f7f43a3ded7fd7709451d26f08c176376ffec
mkdocs-include-markdown-plugin==7.2.2 \
--hash=sha256:f052ccb741eccf498116b826c1d78a2d761c56747372594709441cee0963fbc9 \
--hash=sha256:f2ec4487cf32d3e33ca528f9366f20fb9280ded9c8d1630eb2bbda244962dcd1
# via -r docs/requirements.in
mkdocs-macros-plugin==1.5.0 \
--hash=sha256:12aa45ce7ecb7a445c66b9f649f3dd05e9b92e8af6bc65e4acd91d26f878c01f \
@ -416,9 +422,9 @@ mkdocs-mermaid2-plugin==1.2.3 \
--hash=sha256:33f60c582be623ed53829a96e19284fc7f1b74a1dbae78d4d2e47fe00c3e190d \
--hash=sha256:fb6f901d53e5191e93db78f93f219cad926ccc4d51e176271ca5161b6cc5368c
# via -r docs/requirements.in
mkdocs-redirects==1.2.2 \
--hash=sha256:3094981b42ffab29313c2c1b8ac3969861109f58b2dd58c45fc81cd44bfa0095 \
--hash=sha256:7dbfa5647b79a3589da4401403d69494bd1f4ad03b9c15136720367e1f340ed5
mkdocs-redirects==1.2.3 \
--hash=sha256:5e980330999299729a2d6a125347d1af78023d68a23681a4de3053ce7dfe2e51 \
--hash=sha256:ec7312fff462d03ec16395d0c001006a418f8d0c21cdf2b47ff11cf839dc3ce0
# via -r docs/requirements.in
mkdocs-simple-hooks==0.1.5 \
--hash=sha256:dddbdf151a18723c9302a133e5cf79538be8eb9d274e8e07d2ac3ac34890837c \
@ -445,6 +451,7 @@ packaging==26.0 \
# -c src/backend/requirements.txt
# mkdocs
# mkdocs-macros-plugin
# properdocs
paginate==0.5.7 \
--hash=sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945 \
--hash=sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591
@ -455,12 +462,18 @@ pathspec==1.0.4 \
# via
# mkdocs
# mkdocs-macros-plugin
platformdirs==4.9.4 \
--hash=sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934 \
--hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868
# properdocs
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
# via
# -c src/backend/requirements.txt
# mkdocs-get-deps
# properdocs
properdocs==1.6.7 \
--hash=sha256:6fa0cfa2e01bf338f684892c8a506cf70ea88ae7f3479c933b6fa20168101cbd \
--hash=sha256:adc7b16e562890af0e098a7e5b02e3a81c20894a87d6a28d345c9300de73c26e
# via mkdocs-redirects
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
@ -561,15 +574,18 @@ pyyaml==6.0.3 \
# mkdocs
# mkdocs-get-deps
# mkdocs-macros-plugin
# properdocs
# pymdown-extensions
# pyyaml-env-tag
pyyaml-env-tag==1.1 \
--hash=sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04 \
--hash=sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff
# via mkdocs
requests==2.33.0 \
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via
# mkdocs
# properdocs
requests==2.33.1 \
--hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \
--hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a
# via
# -c src/backend/requirements.txt
# mkdocs-macros-plugin
@ -652,7 +668,9 @@ watchdog==6.0.0 \
--hash=sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8 \
--hash=sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c \
--hash=sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2
# via mkdocs
# via
# mkdocs
# properdocs
wcmatch==10.1 \
--hash=sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a \
--hash=sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af

View File

@ -15,7 +15,7 @@ from django.views.generic.base import RedirectView
import structlog
from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import serializers
from rest_framework import serializers, viewsets
from rest_framework.generics import GenericAPIView
from rest_framework.request import clone_request
from rest_framework.response import Response
@ -297,7 +297,7 @@ class InfoView(APIView):
'instance': InvenTree.version.inventreeInstanceName(),
'apiVersion': InvenTree.version.inventreeApiVersion(),
'worker_running': is_worker_running(),
'worker_count': settings.BACKGROUND_WORKER_COUNT,
'worker_count': settings.Q_CLUSTER['workers'],
'worker_pending_tasks': self.worker_pending_tasks(),
'plugins_enabled': settings.PLUGINS_ENABLED,
'plugins_install_disabled': settings.PLUGINS_INSTALL_DISABLED,
@ -625,12 +625,8 @@ class ParameterListMixin:
return queryset
class BulkDeleteMixin(BulkOperationMixin):
"""Mixin class for enabling 'bulk delete' operations for various models.
Bulk delete allows for multiple items to be deleted in a single API query,
rather than using multiple API calls to the various detail endpoints.
"""
class CommonBulkDeleteMixin(BulkOperationMixin):
"""Helper for creating bulk delete operation on classic cbv and viewsets."""
def validate_delete(self, queryset, request) -> None:
"""Perform validation right before deletion.
@ -655,7 +651,7 @@ class BulkDeleteMixin(BulkOperationMixin):
return queryset
@extend_schema(request=BulkRequestSerializer)
def delete(self, request, *args, **kwargs):
def _delete(self, request, *args, **kwargs):
"""Perform a DELETE operation against this list endpoint.
Note that the typical DRF list endpoint does not support DELETE,
@ -679,6 +675,37 @@ class BulkDeleteMixin(BulkOperationMixin):
return Response({'success': f'Deleted {n_deleted} items'}, status=200)
class BulkDeleteMixin(CommonBulkDeleteMixin):
"""Mixin class for enabling 'bulk delete' operations for various models.
Bulk delete allows for multiple items to be deleted in a single API query,
rather than using multiple API calls to the various detail endpoints.
"""
@extend_schema(request=BulkRequestSerializer)
def delete(self, request, *args, **kwargs):
"""Perform a DELETE operation against this list endpoint.
Note that the typical DRF list endpoint does not support DELETE,
so this method is provided as a custom implementation.
"""
return self._delete(request, *args, **kwargs)
class BulkDeleteViewsetMixin(CommonBulkDeleteMixin, viewsets.GenericViewSet):
"""Mixin class for enabling 'bulk delete' operations for viewsets."""
@extend_schema(request=BulkRequestSerializer)
def bulk_delete(self, request, *args, **kwargs):
"""Perform a bulk delete operation.
Provide either a list of ids (via `items`) or a filter (via `filters`) to select the items to be deleted.
This action is performed attomically, so either all items will be deleted, or none will be deleted.
"""
return self._delete(request, *args, **kwargs)
class ListCreateDestroyAPIView(BulkDeleteMixin, ListCreateAPI):
"""Custom API endpoint which provides BulkDelete functionality in addition to List and Create."""

View File

@ -1,11 +1,42 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 470
INVENTREE_API_VERSION = 479
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v479 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11723
- POST /api//notifications/readall/ now requires a POST action
- POST /api/admin/email/test/ - now returns a 200 on. a successful test
- GET /api/notifications/ - now uses user-centric permissions, not a general read
v478 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11073
- Add OptionalField class for cleaner handling of optional fields in serializers
v477 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11617
- Non-functional refactor, adaptations of descriptions
v476 -> 2026-04-09 : https://github.com/inventree/InvenTree/pull/11705
- Adds sorting / filtering / searching functionality to the SelectionListEntry API endpoint
v475 -> 2026-04-09 : https://github.com/inventree/InvenTree/pull/11702
- Adds "updated" and "updated_by" fields to the LabelTemplate and ReportTemplate API endpoints
v474 -> 2026-04-08 : https://github.com/inventree/InvenTree/pull/11693
- Adds DataImportMixin to the ManufacturerPartList API endpoint
v473 -> 2026-04-08 : https://github.com/inventree/InvenTree/pull/11692
- Adds "line" field to PurchaseOrderLineItem and PurchaseOrderExtraLineItem API endpoints
- Adds "line" field to SalesOrderLineItem and SalesOrderExtraLineItem API endpoints
- Adds "line" field to ReturnOrderLineItem and ReturnOrderExtraLineItem API endpoints
v472 -> 2026-04-01 : https://github.com/inventree/InvenTree/pull/xxxx
- Fixes writable fields on the user detail endpoint
v471 -> 2026-04-07 : https://github.com/inventree/InvenTree/pull/11685
- Adds data importer support for the "SalesOrderShipment" model
v470 -> 2026-04-01 : https://github.com/inventree/InvenTree/pull/11659
- Renames "is_staff" field to "is_admin" and updates help texts accordingly to highlight current security boundaries
@ -13,7 +44,7 @@ v469 -> 2026-03-31 : https://github.com/inventree/InvenTree/pull/11641
- Adds parameter support to the SalesOrderShipment model and API endpoints
v468 -> 2026-03-31 : https://github.com/inventree/InvenTree/pull/11649
- Add ordering to contetype related fields - no functional changes
- Add ordering to contentype related fields - no functional changes
v467 -> 2026-03-20 : https://github.com/inventree/InvenTree/pull/11573
- Fix definition for the "parent" field on the StockItemSerializer

View File

@ -50,6 +50,11 @@ def cache_user():
return cache_setting('user', None)
def cache_db():
"""Return the cache database index."""
return cache_setting('db', 0, typecast=int)
def is_global_cache_enabled() -> bool:
"""Check if the global cache is enabled.
@ -89,49 +94,51 @@ def get_cache_config(global_cache: bool) -> dict:
Returns:
A dictionary containing the cache configuration options.
"""
if global_cache:
# Build Redis URL with optional password
password = cache_password()
user = cache_user() or ''
# Build Redis URL with optional password
password = cache_password()
user = cache_user() or ''
host = cache_host()
port = cache_port()
db = cache_db()
if password:
redis_url = f'redis://{user}:{password}@{cache_host()}:{cache_port()}/0'
else:
redis_url = f'redis://{cache_host()}:{cache_port()}/0'
if password:
redis_url = f'redis://{user}:{password}@{host}:{port}/{db}'
else:
redis_url = f'redis://{host}:{port}/{db}'
keepalive_options = {
'TCP_KEEPCNT': cache_setting('keepalive_count', 5, typecast=int),
'TCP_KEEPIDLE': cache_setting('keepalive_idle', 1, typecast=int),
'TCP_KEEPINTVL': cache_setting('keepalive_interval', 1, typecast=int),
'TCP_USER_TIMEOUT': cache_setting('user_timeout', 1000, typecast=int),
}
keepalive_options = {
'TCP_KEEPCNT': cache_setting('keepalive_count', 5, typecast=int),
'TCP_KEEPIDLE': cache_setting('keepalive_idle', 1, typecast=int),
'TCP_KEEPINTVL': cache_setting('keepalive_interval', 1, typecast=int),
'TCP_USER_TIMEOUT': cache_setting('user_timeout', 1000, typecast=int),
}
return {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': redis_url,
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'SOCKET_CONNECT_TIMEOUT': cache_setting(
'connect_timeout', 5, typecast=int
),
'SOCKET_TIMEOUT': cache_setting('timeout', 3, typecast=int),
'CONNECTION_POOL_KWARGS': {
'socket_keepalive': cache_setting(
'tcp_keepalive', True, typecast=bool
),
'socket_keepalive_options': {
# Only include options which are available on this platform
# e.g. MacOS does not have TCP_KEEPIDLE and TCP_USER_TIMEOUT
getattr(socket, key): value
for key, value in keepalive_options.items()
if hasattr(socket, key)
},
global_cache_config = {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': redis_url,
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'SOCKET_CONNECT_TIMEOUT': cache_setting('connect_timeout', 5, typecast=int),
'SOCKET_TIMEOUT': cache_setting('timeout', 3, typecast=int),
'CONNECTION_POOL_KWARGS': {
'socket_keepalive': cache_setting('tcp_keepalive', True, typecast=bool),
'socket_keepalive_options': {
# Only include options which are available on this platform
# e.g. MacOS does not have TCP_KEEPIDLE and TCP_USER_TIMEOUT
getattr(socket, key): value
for key, value in keepalive_options.items()
if hasattr(socket, key)
},
},
}
},
}
# Default: Use django local memory cache
return {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}
if global_cache:
# Only return the global cache configuration if the global cache is enabled
return global_cache_config
else:
# Default: Use django local memory cache
return {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}
def create_session_cache(request) -> None:

View File

@ -270,10 +270,13 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
def set_metadata(source: str):
"""Set lookup metadata for the setting."""
global CONFIG_LOOKUPS
key = env_var or config_key
CONFIG_LOOKUPS[key] = {
'env_var': env_var,
'config_key': config_key,
'default_value': default_value,
'source': source,
'accessed': datetime.datetime.now(),
}
@ -544,13 +547,14 @@ def get_frontend_settings(debug=True):
'INVENTREE_FRONTEND_SETTINGS', 'frontend_settings', {}, typecast=dict
)
base_url = get_setting(
'INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'web', typecast=str
)
# Set the base URL for the user interface
# This is the UI path e.g. '/web/'
if 'base_url' not in frontend_settings:
frontend_settings['base_url'] = (
get_setting('INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'web')
or 'web'
)
frontend_settings['base_url'] = base_url
# If provided, specify the API host
api_host = frontend_settings.get('api_host', None) or get_setting(

View File

@ -8,6 +8,7 @@ import json
import os.path
import re
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Optional, TypeVar
from wsgiref.util import FileWrapper
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@ -35,9 +36,6 @@ from InvenTree.sanitizer import (
DEFAULT_TAGS,
)
from .setting.storages import StorageBackends
from .settings import MEDIA_URL, STATIC_URL
logger = structlog.get_logger('inventree')
INT_CLIP_MAX = 0x7FFFFFFF
@ -189,9 +187,8 @@ def getMediaUrl(
)
if name is not None:
file = regenerate_imagefile(file, name)
if settings.STORAGE_TARGET == StorageBackends.S3:
return str(file.url)
return os.path.join(MEDIA_URL, str(file.url))
return default_storage.url(file.name)
def regenerate_imagefile(_file, _name: str):
@ -229,7 +226,7 @@ def image2name(img_obj: StdImageField, do_preview: bool, do_thumbnail: bool):
def getStaticUrl(filename):
"""Return the qualified access path for the given file, under the static media directory."""
return os.path.join(STATIC_URL, str(filename))
return StaticFilesStorage().url(filename)
def TestIfImage(img) -> bool:
@ -261,8 +258,8 @@ def getBlankThumbnail():
def checkStaticFile(*args) -> bool:
"""Check if a file exists in the static storage."""
static_storage = StaticFilesStorage()
fn = os.path.join(*args)
return static_storage.exists(fn)
fn = Path(*args)
return static_storage.exists(str(fn))
def getLogoImage(as_file=False, custom=True):

View File

@ -0,0 +1,49 @@
"""Helpers for InvenTrees way of using drf viewset."""
from rest_framework import mixins, routers, viewsets
from InvenTree.api import BulkDeleteViewsetMixin
class RetrieveUpdateDestroyModelViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""Viewset which provides 'retrieve', 'update', 'destroy' and 'list' actions."""
class RetrieveDestroyModelViewSet(
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""Viewset which provides 'retrieve', 'destroy' and 'list' actions."""
class InvenTreeApiRouter(routers.SimpleRouter):
"""Custom router which adds various specific functions.
Currently adds the following features:
- support for bulk delete operations
"""
def get_routes(self, viewset):
"""Override the default get_routes method to add bulk delete support."""
routes = super().get_routes(viewset)
if issubclass(viewset, BulkDeleteViewsetMixin):
list_route = next(
(route for route in routes if route.mapping.get('get') == 'list'), None
)
list_route.mapping['delete'] = 'bulk_delete'
return routes
def get_default_basename(self, viewset):
"""Extract the default base name from the viewset."""
basename = super().get_default_basename(viewset)
return 'api-' + basename

View File

@ -1,9 +1,11 @@
"""Provides helper functions used throughout the InvenTree project that access the database."""
import io
import ipaddress
import socket
from decimal import Decimal
from typing import Optional, cast
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
from django.conf import settings
from django.core.exceptions import ValidationError
@ -88,6 +90,36 @@ def construct_absolute_url(*arg, base_url=None, request=None):
return urljoin(base_url, relative_url)
def validate_url_no_ssrf(url):
"""Validate that a URL does not point to a private/internal network address.
Resolves the hostname to an IP address and checks it against private,
loopback, link-local, and reserved IP ranges to prevent SSRF attacks.
Arguments:
url: The URL to validate
Raises:
ValueError: If the URL resolves to a private or reserved IP address
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise ValueError(_('Invalid URL: no hostname'))
try:
addrinfo = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError(_('Invalid URL: hostname could not be resolved'))
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError(_('URL points to a private or reserved IP address'))
def download_image_from_url(remote_url, timeout=2.5):
"""Download an image file from a remote URL.
@ -115,6 +147,9 @@ def download_image_from_url(remote_url, timeout=2.5):
validator = URLValidator()
validator(remote_url)
# SSRF protection: validate the resolved IP is not private/internal
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
@ -129,10 +164,36 @@ def download_image_from_url(remote_url, timeout=2.5):
response = requests.get(
remote_url,
timeout=timeout,
allow_redirects=True,
allow_redirects=False,
stream=True,
headers=headers,
)
# Handle redirects manually to validate each destination
max_redirects = 5
redirect_count = 0
while response.is_redirect and redirect_count < max_redirects:
redirect_url = response.headers.get('Location')
if not redirect_url:
break
# Validate the redirect destination against SSRF
validator(redirect_url)
validate_url_no_ssrf(redirect_url)
redirect_count += 1
response = requests.get(
redirect_url,
timeout=timeout,
allow_redirects=False,
stream=True,
headers=headers,
)
if redirect_count >= max_redirects:
raise ValueError(_('Too many redirects'))
# Throw an error if anything goes wrong
response.raise_for_status()
except requests.exceptions.ConnectionError as exc:
@ -143,6 +204,8 @@ def download_image_from_url(remote_url, timeout=2.5):
raise requests.exceptions.HTTPError(
_('Server responded with invalid status code') + f': {response.status_code}'
)
except ValueError:
raise
except Exception as exc:
raise Exception(_('Exception occurred') + f': {exc!s}')

View File

@ -24,8 +24,9 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
"""Export settings information to a JSON file."""
from common.models import InvenTreeSetting, InvenTreeUserSetting
from InvenTree.config import CONFIG_LOOKUPS
settings = {'global': {}, 'user': {}}
settings = {'global': {}, 'user': {}, 'config': {}}
# Global settings
for key, setting in InvenTreeSetting.SETTINGS.items():
@ -45,6 +46,18 @@ class Command(BaseCommand):
'units': str(setting.get('units', '')),
}
# Configuration settings
for key, config in CONFIG_LOOKUPS.items():
default_value = config.get('default_value', None)
if default_value is not None:
default_value = str(default_value)
settings['config'][key] = {
'env_var': str(config.get('env_var', '')),
'config_key': str(config.get('config_key', '')),
'default_value': default_value,
}
filename = kwargs.get('filename', 'inventree_settings.json')
with open(filename, 'w', encoding='utf-8') as f:

View File

@ -3,8 +3,7 @@
- May be required after importing a new dataset, for example
"""
import os
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand
from django.db.utils import OperationalError, ProgrammingError
@ -35,7 +34,7 @@ class Command(BaseCommand):
img_paths.append(x.path)
if len(img_paths) > 0:
if all(os.path.exists(path) for path in img_paths):
if all(default_storage.exists(p) for p in img_paths):
# All images exist - skip further work
return

View File

@ -13,25 +13,39 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
"""Wait till the database is ready."""
self.stdout.write('Waiting for database...')
connected = False
verbose = int(kwargs.get('verbosity', 0)) > 0
attempts = kwargs.get('attempts', 10)
while not connected:
time.sleep(2)
if verbose:
self.stdout.write('Waiting for database connection...')
self.stdout.flush()
while not connected and attempts > 0:
attempts -= 1
try:
connection.ensure_connection()
connected = True
except OperationalError as e:
self.stdout.write(f'Could not connect to database: {e}')
except ImproperlyConfigured as e:
self.stdout.write(f'Improperly configured: {e}')
except (OperationalError, ImproperlyConfigured):
if verbose:
self.stdout.write('Database connection failed, retrying ...')
self.stdout.flush()
else:
if not connection.is_usable():
self.stdout.write('Database configuration is not usable')
if verbose:
self.stdout.write('Database configuration is not usable')
self.stdout.flush()
if connected:
self.stdout.write('Database connection successful!')
if verbose:
self.stdout.write('Database connection successful!')
self.stdout.flush()
else:
time.sleep(1)
if not connected:
self.stderr.write('Failed to connect to database after multiple attempts')
self.stderr.flush()

View File

@ -380,6 +380,15 @@ class InvenTreeMetadata(SimpleMetadata):
We take the regular DRF metadata and add our own unique flavor
"""
from InvenTree.serializers import OptionalField
if isinstance(field, OptionalField) or issubclass(
field.__class__, OptionalField
):
# Rehydrate the OptionalField for proper introspection
rehydrated_field = field.serializer_class(**(field.serializer_kwargs or {}))
return self.get_field_info(rehydrated_field)
# Try to add the child property to the dependent field to be used by the super call
if self.label_lookup[field] == 'dependent field':
field.get_child(raise_exception=True)

View File

@ -6,7 +6,6 @@ from rest_framework import generics, mixins, status
from rest_framework.response import Response
import data_exporter.mixins
import data_exporter.serializers
import importer.mixins
from InvenTree.fields import InvenTreeNotesField, OutputConfiguration
from InvenTree.helpers import (
@ -214,20 +213,6 @@ class OutputOptionsMixin:
if getattr(cls, 'output_options', None) is not None:
schema_for_view_output_options(cls)
def __init__(self) -> None:
"""Initialize the mixin. Check that the serializer is compatible."""
super().__init__()
# Check that the serializer was defined
if (
hasattr(self, 'serializer_class')
and isinstance(self.serializer_class, type)
and (not issubclass(self.serializer_class, FilterableSerializerMixin))
):
raise Exception(
'INVE-I2: `OutputOptionsMixin` can only be used with serializers that contain the `FilterableSerializerMixin` mixin'
)
def get_serializer(self, *args, **kwargs):
"""Return serializer instance with output options applied."""
request = getattr(self, 'request', None)
@ -241,20 +226,7 @@ class OutputOptionsMixin:
context['request'] = request
kwargs['context'] = context
serializer = super().get_serializer(*args, **kwargs)
# Check if the serializer actually can be filtered - makes not much sense to use this mixin without that prerequisite
if isinstance(
serializer, data_exporter.serializers.DataExportOptionsSerializer
):
# Skip in this instance, special case for determining export options
pass
elif not isinstance(serializer, FilterableSerializerMixin):
raise Exception(
'INVE-I2: `OutputOptionsMixin` can only be used with serializers that contain the `FilterableSerializerMixin` mixin'
)
return serializer
return super().get_serializer(*args, **kwargs)
def get_queryset(self):
"""Return the queryset with output options applied.

View File

@ -91,25 +91,41 @@ def isCollectingPlugins():
return 'collectplugins' in sys.argv
# This variable is used to cache the result of the isGeneratingSchema function, to prevent multiple executions of the same checks
_IS_GENERATING_SCHEMA: bool | None = None
def _setGeneratingSchema(value: bool):
"""Set the value of the isGeneratingSchema variable."""
global _IS_GENERATING_SCHEMA
_IS_GENERATING_SCHEMA = value
return value
def isGeneratingSchema():
"""Return true if schema generation is being executed."""
global _IS_GENERATING_SCHEMA
if _IS_GENERATING_SCHEMA is not None:
return _IS_GENERATING_SCHEMA
if isInServerThread() or isInWorkerThread():
return False
return _setGeneratingSchema(False)
if isRunningMigrations() or isRunningBackup() or isRebuildingData():
return False
return _setGeneratingSchema(False)
if isImportingData():
return False
return _setGeneratingSchema(False)
if isInTestMode():
return False
return _setGeneratingSchema(False)
if isWaitingForDatabase():
return False
return _setGeneratingSchema(False)
if isCollectingPlugins():
return False
return _setGeneratingSchema(False)
# Additional set of commands which should not trigger schema generation
excluded_commands = [
@ -129,7 +145,7 @@ def isGeneratingSchema():
]
if any(cmd in sys.argv for cmd in excluded_commands):
return False
return _setGeneratingSchema(False)
included_commands = [
'schema',
@ -142,14 +158,14 @@ def isGeneratingSchema():
]
if any(cmd in sys.argv for cmd in included_commands):
return True
return _setGeneratingSchema(True)
# This is a very inefficient call - so we only use it as a last resort
result = any('drf_spectacular' in frame.filename for frame in inspect.stack())
if not result:
# We should only get here if we *are* generating schema
# Raise a warning, so that deevlopers can add extra checks above
# Raise a warning, so that developers can add extra checks above
if settings.DEBUG:
logger.warning(
@ -157,7 +173,7 @@ def isGeneratingSchema():
)
logger.warning('sys.argv: %s', sys.argv)
return result
return _setGeneratingSchema(result)
def isInWorkerThread():
@ -207,6 +223,14 @@ def readOnlyCommands():
def isReadOnlyCommand():
"""Return True if the current command is a read-only command, which should not trigger any database writes."""
if (
isImportingData()
or isRunningMigrations()
or isRebuildingData()
or isRunningBackup()
):
return True
return any(cmd in sys.argv for cmd in readOnlyCommands())

View File

@ -345,4 +345,8 @@ def sanitize_svg(
link_rel=None,
)
# Replace non-breaking spaces with regular spaces to prevent SVG rendering issues
for nbsp in ['&nbsp;', '&#160;']:
cleaned = cleaned.replace(nbsp, ' ')
return cleaned

View File

@ -55,9 +55,17 @@ class ExtendedAutoSchema(AutoSchema):
result_id = super().get_operation_id()
# rename bulk actions to deconflict with single action operation_id
if (self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin')) or (
(self.method == 'PUT' or self.method == 'PATCH')
and self.is_bulk_action('BulkUpdateMixin')
if (
(self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin'))
or (
self.method == 'DELETE'
and self.is_bulk_action('BulkDeleteViewsetMixin')
and self.view.action == 'bulk_delete'
)
or (
(self.method == 'PUT' or self.method == 'PATCH')
and self.is_bulk_action('BulkUpdateMixin')
)
):
action = self.method_mapping[self.method.lower()]
result_id = result_id.replace(action, 'bulk_' + action)
@ -81,7 +89,11 @@ class ExtendedAutoSchema(AutoSchema):
# drf-spectacular doesn't support a body on DELETE endpoints because the semantics are not well-defined and
# OpenAPI recommends against it. This allows us to generate a schema that follows existing behavior.
if self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin'):
if (self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin')) or (
self.method == 'DELETE'
and getattr(self.view, 'action', None) == 'bulk_delete'
and self.is_bulk_action('BulkDeleteViewsetMixin')
):
original_method = self.method
self.method = 'PUT'
request_body = self._get_request_body()

View File

@ -2,8 +2,9 @@
from collections import OrderedDict
from copy import deepcopy
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Optional
from typing import Optional
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
@ -21,9 +22,9 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import empty
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import SAFE_METHODS
from rest_framework.serializers import DecimalField
from rest_framework.serializers import DecimalField, Serializer
from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer, TagListSerializerField
from taggit.serializers import TaggitSerializer
import common.models as common_models
import InvenTree.ready
@ -33,94 +34,239 @@ from InvenTree.helpers import str2bool
from InvenTree.helpers_model import getModelsWithMixin
# region path filtering
class FilterableSerializerField:
"""Mixin to mark serializer as filterable.
@dataclass
class OptionalField:
"""DataClass used to optionally enable a serializer field.
This needs to be used in conjunction with `enable_filter` on the serializer field!
"""
This is used in conjunction with the `FilterableSerializerMixin` to allow
dynamic inclusion or exclusion of serializer fields at runtime.
is_filterable = None
is_filterable_vals = {}
Adding OptionalField instances to a serializer class is more "efficient"
than directly adding the field (and later removing it),
as the field is never instantiated unless it is required.
# Options for automatic queryset prefetching
prefetch_fields: Optional[list[str]] = None
Additionally, you can specify prefetch fields which will be applied
to the queryset, *only* if the field is included in the final serializer.
def __init__(self, *args, **kwargs):
"""Initialize the serializer."""
self.is_filterable = kwargs.pop('is_filterable', None)
self.is_filterable_vals = kwargs.pop('is_filterable_vals', {})
self.prefetch_fields = kwargs.pop('prefetch_fields', None)
This allows for optimization of database queries based only on the requested data.
super().__init__(*args, **kwargs)
def enable_filter(
func: Any,
default_include: bool = False,
filter_name: Optional[str] = None,
filter_by_query: bool = True,
prefetch_fields: Optional[list[str]] = None,
):
"""Decorator for marking a serializer field as filterable.
This can be customized by passing in arguments. This only works in conjunction with serializer fields or serializers that contain the `FilterableSerializerField` mixin.
Args:
func: The serializer field to mark as filterable. Will automatically be passed when used as a decorator.
default_include (bool): If True, the field will be included by default unless explicitly excluded. If False, the field will be excluded by default unless explicitly included.
filter_name (str, optional): The name of the filter parameter to use in the URL. If None, the function name of the (decorated) function will be used.
filter_by_query (bool): If True, also look for filter parameters in the request query parameters.
prefetch_fields (list of str, optional): List of related fields to prefetch when this field is included. This can be used to optimize database queries.
Returns:
The decorated serializer field, marked as filterable.
"""
# Ensure this function can be actually filtered
if not issubclass(func.__class__, FilterableSerializerField):
raise TypeError(
'INVE-I2: `enable_filter` can only be applied to serializer fields / serializers that contain the `FilterableSerializerField` mixin!'
Example:
class MySerializer(FilterableSerializerMixin, serializers.ModelSerializer):
my_optional_field = OptionalField(
serializer_class=serializers.CharField,
default_include=False,
filter_name='include_my_field',
serializer_kwargs={
'help_text': 'This is an optional field',
'read_only': True,
},
prefetch_fields=['related_field'],
)
# Mark the function as filterable
func._kwargs['is_filterable'] = True
func._kwargs['is_filterable_vals'] = {
'default': default_include,
'filter_name': filter_name if filter_name else func.field_name,
'filter_by_query': filter_by_query,
}
"""
# Attach queryset prefetching information
func._kwargs['prefetch_fields'] = prefetch_fields
return func
serializer_class: Serializer
serializer_kwargs: Optional[dict] = None
default_include: bool = False
filter_name: Optional[str] = None
filter_by_query: bool = True
prefetch_fields: Optional[list[str]] = None
class FilterableSerializerMixin:
"""Mixin that enables filtering of marked fields on a serializer.
Use the `enable_filter` decorator to mark serializer fields as filterable.
Use the `OptionalField` helper class to mark serializer fields as filterable.
This introduces overhead during initialization, so only use this mixin when necessary.
If you need to mark a serializer as filterable but it does not contain any filterable fields, set `no_filters = True` to avoid getting an exception that protects against over-application of this mixin.
"""
_was_filtered = False
no_filters = False
"""If True, do not raise an exception if no filterable fields are found."""
filter_on_query = True
"""If True, also look for filter parameters in the request query parameters."""
optional_filters: dict = None
fields_to_remove: set = None
optional_fields: set = None
filter_on_query: bool = True
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer. This gathers and applies filters through kwargs."""
# add list_serializer_class to meta if not present - reduces duplication
if not isinstance(self, FilterableListSerializer) and (
not hasattr(self.Meta, 'list_serializer_class')
):
self.Meta.list_serializer_class = FilterableListSerializer
# Extract some useful context information for later use
context = kwargs.get('context', {})
self.request = context.get('request', None) or getattr(self, 'request', None)
self.request_query_params = (
dict(getattr(self.request, 'query_params', {})) if self.request else {}
)
self.gather_optional_fields(kwargs)
self.gather_filters(kwargs)
super().__init__(*args, **kwargs)
self.do_filtering()
# Ensure any fields we are *not* using are removed
for field_name in self.fields_to_remove:
self.fields.pop(field_name, None)
def is_exporting(self) -> bool:
"""Determine if we are exporting data."""
return getattr(self, '_exporting_data', False)
def is_field_included(
self, field_name: str, field: OptionalField, kwargs: dict
) -> bool:
"""Determine at runtime whether an OptionalField should be included.
Arguments:
field_name: Name of the field
field: The OptionalField instance
kwargs: The kwargs provided to the serializer instance
Returns:
True if the field should be included, False otherwise.
Order of operations:
- If we are generating the schema, always include the field
- If this is a write request (POST, PUT, PATCH) and we are not exporting, always include the field
- If this is a top-level serializer, check the request query parameters for the filter name
- Check the kwargs provided to the serializer instance
- Finally, fall back to the default_include value for the field itself
"""
field_ref = field.filter_name or field_name
# If we have already found a value for this filter, use it
# This allows multiple optional fields to share the same filter value
cached_value = self.optional_filters.get(field_ref, None)
if cached_value is not None:
return cached_value
# First, check kwargs provided to the serializer instance
# We also pop the value to avoid issues with nested serializers
value = kwargs.pop(field_ref, None)
# We do not want to pop fields while generating the schema
if InvenTree.ready.isGeneratingSchema():
return True
if value is not None:
# Cache the value for future reference
self.optional_filters[field_ref] = value
field_kwargs = field.serializer_kwargs or {}
# Skip filtering for a write request - all fields should be present for data creation
if method := getattr(self.request, 'method', None):
if method not in SAFE_METHODS and not self.is_exporting():
return True
else:
# Ignore write_only fields for read requests
if field_kwargs.get('write_only', False):
return False
# For a top-level serializer, check request query parameters
if self.request and self.filter_on_query and field.filter_by_query:
param_value = self.request.query_params.get(field_ref, None)
if param_value is not None:
# Convert from list to single value if needed
if type(param_value) == list and len(param_value) == 1:
param_value = param_value[0]
value = str2bool(param_value)
# Cache the value for future reference
self.optional_filters[field_ref] = value
if value is None:
value = field.default_include
return value
def find_optional_fields(self):
"""Find all optional fields defined on this serializer."""
optional_fields = {}
# Walk upwards through the class hierarchy
seen_vars = set()
for base in self.__class__.__mro__:
for field_name, field in vars(base).items():
if field_name in seen_vars:
continue
seen_vars.add(field_name)
if field and isinstance(field, OptionalField):
optional_fields[field_name] = field
return optional_fields
def gather_optional_fields(self, kwargs):
"""Determine which optional fields will be included on this serializer.
Note that there may be instances of OptionalField in the field set,
which need to either be instantiated or removed.
"""
self.optional_filters = {}
self.prefetch_list = set()
self.fields_to_remove = set()
self.optional_fields = set()
for field_name, field in self.find_optional_fields().items():
if self.is_field_included(field_name, field, kwargs):
self.optional_fields.add(field_name)
# Add prefetch information
if field.prefetch_fields:
for pf in field.prefetch_fields:
self.prefetch_list.add(pf)
else:
self.fields_to_remove.add(field_name)
def get_field_names(self, declared_fields, info):
"""Remove unused fields before returning field names."""
field_names = super().get_field_names(declared_fields, info)
# Add any optional fields which are included
for field_name in self.optional_fields:
if field_name not in field_names:
field_names.append(field_name)
# Remove any fields which are marked for removal
for field_name in self.fields_to_remove:
if field_name in field_names:
field_names.remove(field_name)
return field_names
def build_optional_field(self, field_name: str):
"""Build an optional field, based on the provided field name."""
field = getattr(self, field_name, None)
if field and isinstance(field, OptionalField):
serializer_kwargs = {**field.serializer_kwargs} or {}
return field.serializer_class, serializer_kwargs
def build_relational_field(self, field_name, relation_info):
"""Handle a special case where an OptionalField shadows a model relation."""
if field_name in self.optional_fields:
if field := self.build_optional_field(field_name):
return field
return super().build_relational_field(field_name, relation_info)
def build_property_field(self, field_name, model_class):
"""Handle a special case where an OptionalField shadows a model property."""
if field_name in self.optional_fields:
if field := self.build_optional_field(field_name):
return field
return super().build_property_field(field_name, model_class)
def build_unknown_field(self, field_name, model_class):
"""Perform lazy initialization of OptionalFields.
The DRF framework calls this method when it encounters a field which is not yet initialized.
"""
if field := self.build_optional_field(field_name):
return field
return super().build_unknown_field(field_name, model_class)
def prefetch_queryset(self, queryset: QuerySet) -> QuerySet:
"""Apply any prefetching to the queryset based on the optionally included fields.
@ -140,157 +286,48 @@ class FilterableSerializerMixin:
if getattr(request, '_metadata_requested', False):
return queryset
# Gather up the set of simple 'prefetch' fields and functions
prefetch_fields = set()
filterable_fields = [
field
for field in self.fields.values()
if getattr(field, 'is_filterable', None)
]
for field in filterable_fields:
if prefetch_names := getattr(field, 'prefetch_fields', None):
for pf in prefetch_names:
prefetch_fields.add(pf)
if prefetch_fields and len(prefetch_fields) > 0:
queryset = queryset.prefetch_related(*list(prefetch_fields))
if self.prefetch_list and len(self.prefetch_list) > 0:
queryset = queryset.prefetch_related(*list(self.prefetch_list))
return queryset
def gather_filters(self, kwargs) -> None:
"""Gather filterable fields through introspection."""
context = kwargs.get('context', {})
request = context.get('request', None) or getattr(self, 'request', None)
# Gather query parameters from the request context
query_params = dict(getattr(request, 'query_params', {})) if request else {}
# Fast exit if this has already been done or would not have any effect
if getattr(self, '_was_filtered', False) or not hasattr(self, 'fields'):
return
# Actually gather the filterable fields
# Also see `enable_filter` where` is_filterable and is_filterable_vals are set
self.filter_targets: dict[str, dict] = {
str(k): {'serializer': a, **getattr(a, 'is_filterable_vals', {})}
for k, a in self.fields.items()
if getattr(a, 'is_filterable', None)
}
# Remove filter args from kwargs to avoid issues with super().__init__
popped_kwargs = {} # store popped kwargs as a arg might be reused for multiple fields
tgs_vals: dict[str, bool] = {}
for k, v in self.filter_targets.items():
pop_ref = v['filter_name'] or k
val = kwargs.pop(pop_ref, popped_kwargs.get(pop_ref))
# Optionally also look in query parameters
# Note that we only do this for a top-level serializer, to avoid issues with nested serializers
if (
request
and val is None
and self.filter_on_query
and v.get('filter_by_query', True)
):
val = query_params.pop(pop_ref, None)
if isinstance(val, list) and len(val) == 1:
val = val[0]
if val: # Save popped value for reuse
popped_kwargs[pop_ref] = val
tgs_vals[k] = (
str2bool(val) if isinstance(val, (str, int, float)) else val
) # Support for various filtering style for backwards compatibility
self.filter_target_values = tgs_vals
self._was_filtered = True
# Ensure this mixin is not broadly applied as it is expensive on scale (total CI time increased by 21% when running all coverage tests)
if len(self.filter_targets) == 0 and not self.no_filters:
raise Exception(
'INVE-I2: No filter targets found in fields, remove `PathScopedMixin`'
)
def do_filtering(self) -> None:
"""Do the actual filtering."""
# This serializer might not contain filters or we do not want to pop fields while generating the schema
if (
not hasattr(self, 'filter_target_values')
or InvenTree.ready.isGeneratingSchema()
):
return
is_exporting = getattr(self, '_exporting_data', False)
# Skip filtering for a write requests - all fields should be present for data creation
if request := self.context.get('request', None):
if method := getattr(request, 'method', None):
if method not in SAFE_METHODS and not is_exporting:
return
# Throw out fields which are not requested (either by default or explicitly)
for k, v in self.filter_target_values.items():
# See `enable_filter` where` is_filterable and is_filterable_vals are set
value = v if v is not None else bool(self.filter_targets[k]['default'])
if value is not True:
self.fields.pop(k, None)
# special serializers which allow filtering
class FilterableListSerializer(
FilterableSerializerField, FilterableSerializerMixin, serializers.ListSerializer
):
"""Custom ListSerializer which allows filtering of fields."""
# special serializer fields which allow filtering
class FilterableListField(FilterableSerializerField, serializers.ListField):
"""Custom ListField which allows filtering."""
class FilterableSerializerMethodField(
FilterableSerializerField, serializers.SerializerMethodField
):
"""Custom SerializerMethodField which allows filtering."""
class FilterableDateTimeField(FilterableSerializerField, serializers.DateTimeField):
"""Custom DateTimeField which allows filtering."""
class FilterableFloatField(FilterableSerializerField, serializers.FloatField):
"""Custom FloatField which allows filtering."""
class FilterableCharField(FilterableSerializerField, serializers.CharField):
"""Custom CharField which allows filtering."""
class FilterableIntegerField(FilterableSerializerField, serializers.IntegerField):
"""Custom IntegerField which allows filtering."""
class FilterableTagListField(FilterableSerializerField, TagListSerializerField):
"""Custom TagListSerializerField which allows filtering."""
class Meta:
"""Empty Meta class."""
# endregion
class EmptySerializer(serializers.Serializer):
"""Empty serializer for use in testing."""
class InvenTreeMoneySerializer(FilterableSerializerField, MoneyField):
class TreePathSerializer(serializers.Serializer):
"""Serializer field for representing a tree path."""
class Meta:
"""Metaclass options."""
fields = [
'pk',
'name',
# Any fields after this point are optional, and can be included via extra_fields
'icon',
]
def __init__(self, *args, extra_fields: Optional[list[str]] = None, **kwargs):
"""Initialize the TreePathSerializer."""
super().__init__(*args, **kwargs)
allowed_fields = ['pk', 'name', *(extra_fields or [])]
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)
class InvenTreeMoneySerializer(MoneyField):
"""Custom serializer for 'MoneyField', which ensures that passed values are numerically valid.
Ref: https://github.com/django-money/django-money/blob/master/djmoney/contrib/django_rest_framework/fields.py
This field allows filtering.
"""
def __init__(self, *args, **kwargs):
@ -477,7 +514,7 @@ class DependentField(serializers.Field):
return None
class InvenTreeModelSerializer(FilterableSerializerField, serializers.ModelSerializer):
class InvenTreeModelSerializer(serializers.ModelSerializer):
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
# Switch out URLField mapping

View File

@ -1,5 +1,7 @@
"""Configuration settings specific to a particular database backend."""
from pathlib import Path
import structlog
from InvenTree.config import get_boolean_setting, get_setting
@ -7,6 +9,62 @@ from InvenTree.config import get_boolean_setting, get_setting
logger = structlog.get_logger('inventree')
def get_db_backend():
"""Return the database backend configuration."""
db_config = {
'ENGINE': get_setting('INVENTREE_DB_ENGINE', 'database.engine', None),
'NAME': get_setting('INVENTREE_DB_NAME', 'database.name', None),
'USER': get_setting('INVENTREE_DB_USER', 'database.user', None),
'PASSWORD': get_setting('INVENTREE_DB_PASSWORD', 'database.password', None),
'HOST': get_setting('INVENTREE_DB_HOST', 'database.host', None),
'PORT': get_setting('INVENTREE_DB_PORT', 'database.port', 5432, typecast=int),
'OPTIONS': get_setting(
'INVENTREE_DB_OPTIONS', 'database.options', {}, typecast=dict
)
or {},
}
# Check for required keys
required_keys = ['ENGINE', 'NAME']
for key in required_keys:
if not db_config[key]:
raise ValueError(
f'Missing required database configuration key: INVENTREE_DB_{key}'
)
DB_ENGINE = db_config['ENGINE'].lower()
# Correct common misspelling
if DB_ENGINE == 'sqlite':
DB_ENGINE = 'sqlite3' # pragma: no cover
if DB_ENGINE in ['sqlite3', 'postgresql', 'mysql']:
# Prepend the required python module string
DB_ENGINE = f'django.db.backends.{DB_ENGINE}'
db_config['ENGINE'] = DB_ENGINE
if 'sqlite' in DB_ENGINE:
db_name = str(Path(db_config['NAME']).resolve())
db_config['NAME'] = db_name
logger.info('DB_ENGINE: %s', DB_ENGINE)
logger.info('DB_NAME: %s', db_config['NAME'])
logger.info('DB_HOST: %s', db_config.get('HOST', "''"))
# Set testing options for the database
db_config['TEST'] = {'CHARSET': 'utf8'}
# Set collation option for mysql test database
if 'mysql' in DB_ENGINE:
db_config['TEST']['COLLATION'] = 'utf8_general_ci' # pragma: no cover
# Specify database specific configuration
set_db_options(DB_ENGINE, db_config['OPTIONS'])
return db_config
def set_db_options(engine: str, db_options: dict):
"""Update database options based on the specified database backend.

View File

@ -0,0 +1,139 @@
"""Configuration of LDAP support for InvenTree."""
from InvenTree.config import get_boolean_setting, get_setting
def get_ldap_config(debug: bool = False) -> dict:
"""Return a dictionary of LDAP configuration settings.
The returned settings will be updated into the globals() object,
and will be used to configure the LDAP authentication backend.
"""
import django_auth_ldap.config # type: ignore[unresolved-import]
import ldap # type: ignore[unresolved-import]
# get global options from dict and use ldap.OPT_* as keys and values
global_options_dict = get_setting(
'INVENTREE_LDAP_GLOBAL_OPTIONS',
'ldap.global_options',
default_value=None,
typecast=dict,
)
global_options = {}
for k, v in global_options_dict.items():
# keys are always ldap.OPT_* constants
k_attr = getattr(ldap, k, None)
if not k.startswith('OPT_') or k_attr is None:
print(f"[LDAP] ldap.global_options, key '{k}' not found, skipping...")
continue
# values can also be other strings, e.g. paths
v_attr = v
if v.startswith('OPT_'):
v_attr = getattr(ldap, v, None)
if v_attr is None:
print(f"[LDAP] ldap.global_options, value key '{v}' not found, skipping...")
continue
global_options[k_attr] = v_attr
if debug:
print('[LDAP] ldap.global_options =', global_options)
group_type_class = get_setting(
'INVENTREE_LDAP_GROUP_TYPE_CLASS',
'ldap.group_type_class',
'GroupOfUniqueNamesType',
str,
)
group_type_class_args = get_setting(
'INVENTREE_LDAP_GROUP_TYPE_CLASS_ARGS', 'ldap.group_type_class_args', [], list
)
group_type_class_kwargs = get_setting(
'INVENTREE_LDAP_GROUP_TYPE_CLASS_KWARGS',
'ldap.group_type_class_kwargs',
{'name_attr': 'cn'},
dict,
)
group_object_class = get_setting(
'INVENTREE_LDAP_GROUP_OBJECT_CLASS',
'ldap.group_object_class',
'groupOfUniqueNames',
str,
)
ldap_config = {
'AUTH_LDAP_GLOBAL_OPTIONS': global_options,
'AUTH_LDAP_SERVER_URI': get_setting(
'INVENTREE_LDAP_SERVER_URI', 'ldap.server_uri'
),
'AUTH_LDAP_START_TLS': get_boolean_setting(
'INVENTREE_LDAP_START_TLS', 'ldap.start_tls', False
),
'AUTH_LDAP_BIND_DN': get_setting('INVENTREE_LDAP_BIND_DN', 'ldap.bind_dn'),
'AUTH_LDAP_BIND_PASSWORD': get_setting(
'INVENTREE_LDAP_BIND_PASSWORD', 'ldap.bind_password'
),
'AUTH_LDAP_USER_SEARCH': django_auth_ldap.config.LDAPSearch(
get_setting('INVENTREE_LDAP_SEARCH_BASE_DN', 'ldap.search_base_dn'),
ldap.SCOPE_SUBTREE,
str(
get_setting(
'INVENTREE_LDAP_SEARCH_FILTER_STR',
'ldap.search_filter_str',
'(uid= %(user)s)',
)
),
),
'AUTH_LDAP_USER_DN_TEMPLATE': get_setting(
'INVENTREE_LDAP_USER_DN_TEMPLATE', 'ldap.user_dn_template'
),
'AUTH_LDAP_USER_ATTR_MAP': get_setting(
'INVENTREE_LDAP_USER_ATTR_MAP',
'ldap.user_attr_map',
{'first_name': 'givenName', 'last_name': 'sn', 'email': 'mail'},
dict,
),
'AUTH_LDAP_ALWAYS_UPDATE_USER': get_boolean_setting(
'INVENTREE_LDAP_ALWAYS_UPDATE_USER', 'ldap.always_update_user', True
),
'AUTH_LDAP_CACHE_TIMEOUT': get_setting(
'INVENTREE_LDAP_CACHE_TIMEOUT', 'ldap.cache_timeout', 3600, int
),
'AUTH_LDAP_MIRROR_GROUPS': get_boolean_setting(
'INVENTREE_LDAP_MIRROR_GROUPS', 'ldap.mirror_groups', False
),
'AUTH_LDAP_GROUP_OBJECT_CLASS': group_object_class,
'AUTH_LDAP_GROUP_SEARCH': django_auth_ldap.config.LDAPSearch(
get_setting('INVENTREE_LDAP_GROUP_SEARCH', 'ldap.group_search'),
ldap.SCOPE_SUBTREE,
f'(objectClass={group_object_class})',
),
'AUTH_LDAP_GROUP_TYPE_CLASS': group_type_class,
'AUTH_LDAP_GROUP_TYPE_CLASS_ARGS': [*group_type_class_args],
'AUTH_LDAP_GROUP_TYPE_CLASS_KWARGS': {**group_type_class_kwargs},
'AUTH_LDAP_GROUP_TYPE': getattr(django_auth_ldap.config, group_type_class)(
*group_type_class_args, **group_type_class_kwargs
),
'AUTH_LDAP_REQUIRE_GROUP': get_setting(
'INVENTREE_LDAP_REQUIRE_GROUP', 'ldap.require_group'
),
'AUTH_LDAP_DENY_GROUP': get_setting(
'INVENTREE_LDAP_DENY_GROUP', 'ldap.deny_group'
),
'AUTH_LDAP_USER_FLAGS_BY_GROUP': get_setting(
'INVENTREE_LDAP_USER_FLAGS_BY_GROUP',
'ldap.user_flags_by_group',
default_value=None,
typecast=dict,
),
'AUTH_LDAP_FIND_GROUP_PERMS': True,
}
return ldap_config

View File

@ -0,0 +1,61 @@
"""Start-up configuration options for InvenTree tracing integrations."""
import structlog
from InvenTree.config import get_boolean_setting, get_setting
from InvenTree.tracing import setup_instruments, setup_tracing
logger = structlog.get_logger('inventree')
def configure_tracing(db_engine: str, enabled: bool, tags: dict) -> dict:
"""Configure tracing integrations for InvenTree.
Arguments:
db_engine: The database engine being used
enabled: Whether tracing is enabled
tags: A dictionary of tags to be included in tracing spans, with keys prefixed by
"""
endpoint = get_setting('INVENTREE_TRACING_ENDPOINT', 'tracing.endpoint', None)
headers = (
get_setting('INVENTREE_TRACING_HEADERS', 'tracing.headers', None, typecast=dict)
or {}
)
if enabled and endpoint:
logger.info('Tracing enabled with endpoint: %s', endpoint)
TRACING_DETAILS = {
'endpoint': endpoint,
'headers': headers,
'resources_input': {
**{'inventree.env.' + k: v for k, v in tags.items()},
**get_setting(
'INVENTREE_TRACING_RESOURCES',
'tracing.resources',
default_value=None,
typecast=dict,
),
},
'console': get_boolean_setting(
'INVENTREE_TRACING_CONSOLE', 'tracing.console', False
),
'auth': get_setting(
'INVENTREE_TRACING_AUTH', 'tracing.auth', default_value=None, typecast=dict
),
'is_http': get_setting('INVENTREE_TRACING_IS_HTTP', 'tracing.is_http', True),
'append_http': get_boolean_setting(
'INVENTREE_TRACING_APPEND_HTTP', 'tracing.append_http', True
),
}
if not enabled:
return None
if endpoint:
setup_tracing(**TRACING_DETAILS)
setup_instruments(db_engine)
else:
logger.warning('OpenTelemetry tracing not enabled because endpoint is not set')
return TRACING_DETAILS

View File

@ -0,0 +1,88 @@
"""Configuration settings for the InvenTree background worker process."""
import sys
from InvenTree.config import get_setting
def get_worker_config(
db_engine: str,
global_cache: bool = False,
sentry_dsn: str = '',
debug: bool = False,
) -> dict:
"""Return a dictionary of configuration settings for the background worker.
Arguments:
db_engine: The database engine being used (e.g. 'sqlite', 'postgresql', 'mysql')
global_cache: Whether a global redis cache is enabled
sentry_dsn: The DSN for sentry.io integration (if enabled)
debug: Whether the application is running in debug mode
Ref: https://django-q2.readthedocs.io/en/master/configure.html
"""
BACKGROUND_WORKER_TIMEOUT = int(
get_setting('INVENTREE_BACKGROUND_TIMEOUT', 'background.timeout', 90)
)
# Set the retry time for background workers to be slightly longer than the worker timeout, to ensure that workers have time to timeout before being retried
BACKGROUND_WORKER_RETRY = max(
int(get_setting('INVENTREE_BACKGROUND_RETRY', 'background.retry', 300)),
BACKGROUND_WORKER_TIMEOUT + 120,
)
BACKGROUND_WORKER_ATTEMPTS = int(
get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5)
)
# Prevent running multiple background workers if global cache is disabled
# This is to prevent scheduling conflicts due to the lack of a shared cache
BACKGROUND_WORKER_COUNT = int(
get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4)
)
# If global cache is disabled, we cannot run multiple background workers
if not global_cache:
BACKGROUND_WORKER_COUNT = 1
# If running with SQLite, limit background worker threads to 1 to prevent database locking issues
if 'sqlite' in db_engine:
BACKGROUND_WORKER_COUNT = 1
# Check if '--sync' was passed in the command line
if '--sync' in sys.argv and '--noreload' in sys.argv and debug:
SYNC_TASKS = True
else:
SYNC_TASKS = False
# Clean up sys.argv so Django doesn't complain about an unknown argument
if SYNC_TASKS:
sys.argv.remove('--sync')
# django-q background worker configuration
config = {
'name': 'InvenTree',
'label': 'Background Tasks',
'workers': BACKGROUND_WORKER_COUNT,
'timeout': BACKGROUND_WORKER_TIMEOUT,
'retry': BACKGROUND_WORKER_RETRY,
'max_attempts': BACKGROUND_WORKER_ATTEMPTS,
'save_limit': 1000,
'queue_limit': 50,
'catch_up': False,
'bulk': 10,
'orm': 'default',
'cache': 'default',
'sync': SYNC_TASKS,
'poll': 1.5,
}
if global_cache:
# If using external redis cache, make the cache the broker for Django Q
config['django_redis'] = 'worker'
if sentry_dsn:
# If sentry is enabled, configure django-q to report errors to sentry
config['error_reporter'] = {'sentry': {'dsn': sentry_dsn}}
return config

View File

@ -12,7 +12,6 @@ database setup in this file.
import logging
import os
import sys
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@ -33,7 +32,16 @@ from InvenTree.version import checkMinPythonVersion, inventreeCommitHash
from users.oauth2_scopes import oauth2_scopes
from . import config
from .setting import db_backend, locales, markdown, spectacular, storages
from .setting import (
db_backend,
ldap,
locales,
markdown,
spectacular,
storages,
tracing,
worker,
)
try:
import django_stubs_ext
@ -81,6 +89,8 @@ DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', False)
# Internal flag to determine if we are running in docker mode
DOCKER = get_boolean_setting('INVENTREE_DOCKER', default_value=False)
AUTO_UPDATE = get_boolean_setting('INVENTREE_AUTO_UPDATE', 'auto_update', False)
# Configure logging settings
LOG_LEVEL = get_setting('INVENTREE_LOG_LEVEL', 'log_level', 'WARNING')
JSON_LOG = get_boolean_setting('INVENTREE_JSON_LOG', 'json_log', False)
@ -201,6 +211,11 @@ PLUGINS_INSTALL_DISABLED = get_boolean_setting(
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
)
if not PLUGINS_ENABLED:
PLUGINS_INSTALL_DISABLED = (
True # If plugins are disabled, also disable installation
)
PLUGIN_FILE = config.get_plugin_file()
# Plugin test settings
@ -217,9 +232,7 @@ PLUGIN_TESTING_EVENTS_ASYNC = False # Flag if events are tested asynchronously
PLUGIN_TESTING_RELOAD = False # Flag if plugin reloading is in testing (check_reload)
# Plugin development settings
PLUGIN_DEV_SLUG = (
get_setting('INVENTREE_PLUGIN_DEV_SLUG', 'plugin_dev.slug') if DEBUG else None
)
PLUGIN_DEV_SLUG = get_setting('INVENTREE_PLUGIN_DEV_SLUG', 'plugin_dev.slug')
PLUGIN_DEV_HOST = get_setting(
'INVENTREE_PLUGIN_DEV_HOST', 'plugin_dev.host', 'http://localhost:5174'
@ -381,8 +394,11 @@ MIDDLEWARE = CONFIG.get(
# In DEBUG mode, add support for django-silk
# Ref: https://silk.readthedocs.io/en/latest/
DJANGO_SILK_ENABLED = DEBUG and get_boolean_setting( # pragma: no cover
'INVENTREE_DEBUG_SILK', 'debug_silk', False
DJANGO_SILK_ENABLED = (
get_boolean_setting( # pragma: no cover
'INVENTREE_DEBUG_SILK', 'debug_silk', False
)
and DEBUG
)
if DJANGO_SILK_ENABLED: # pragma: no cover
@ -396,8 +412,11 @@ if DJANGO_SILK_ENABLED: # pragma: no cover
# In DEBUG mode, add support for django-querycount
# Ref: https://github.com/bradmontgomery/django-querycount
if DEBUG and get_boolean_setting( # pragma: no cover
'INVENTREE_DEBUG_QUERYCOUNT', 'debug_querycount', False
if (
get_boolean_setting( # pragma: no cover
'INVENTREE_DEBUG_QUERYCOUNT', 'debug_querycount', False
)
and DEBUG
):
MIDDLEWARE.append('querycount.middleware.QueryCountMiddleware')
logger.debug('Running with debug_querycount middleware enabled')
@ -432,14 +451,14 @@ AUTHENTICATION_BACKENDS = (
# LDAP support
LDAP_AUTH = get_boolean_setting('INVENTREE_LDAP_ENABLED', 'ldap.enabled', False)
if LDAP_AUTH: # pragma: no cover
import django_auth_ldap.config # type: ignore[unresolved-import]
import ldap # type: ignore[unresolved-import]
LDAP_DEBUG = (
get_boolean_setting('INVENTREE_LDAP_DEBUG', 'ldap.debug', False) and LDAP_AUTH
)
if LDAP_AUTH: # pragma: no cover
AUTHENTICATION_BACKENDS.append('django_auth_ldap.backend.LDAPBackend')
# debug mode to troubleshoot configuration
LDAP_DEBUG = get_boolean_setting('INVENTREE_LDAP_DEBUG', 'ldap.debug', False)
if LDAP_DEBUG:
if 'loggers' not in LOGGING:
LOGGING['loggers'] = {}
@ -448,113 +467,10 @@ if LDAP_AUTH: # pragma: no cover
'handlers': DEFAULT_LOG_HANDLER,
}
# get global options from dict and use ldap.OPT_* as keys and values
global_options_dict = get_setting(
'INVENTREE_LDAP_GLOBAL_OPTIONS',
'ldap.global_options',
default_value=None,
typecast=dict,
)
global_options = {}
for k, v in global_options_dict.items():
# keys are always ldap.OPT_* constants
k_attr = getattr(ldap, k, None)
if not k.startswith('OPT_') or k_attr is None:
print(f"[LDAP] ldap.global_options, key '{k}' not found, skipping...")
continue
# Determine LDAP settings
ldap_settings = ldap.get_ldap_config(debug=LDAP_DEBUG)
globals().update(ldap_settings)
# values can also be other strings, e.g. paths
v_attr = v
if v.startswith('OPT_'):
v_attr = getattr(ldap, v, None)
if v_attr is None:
print(f"[LDAP] ldap.global_options, value key '{v}' not found, skipping...")
continue
global_options[k_attr] = v_attr
AUTH_LDAP_GLOBAL_OPTIONS = global_options
if LDAP_DEBUG:
print('[LDAP] ldap.global_options =', global_options)
AUTH_LDAP_SERVER_URI = get_setting('INVENTREE_LDAP_SERVER_URI', 'ldap.server_uri')
AUTH_LDAP_START_TLS = get_boolean_setting(
'INVENTREE_LDAP_START_TLS', 'ldap.start_tls', False
)
AUTH_LDAP_BIND_DN = get_setting('INVENTREE_LDAP_BIND_DN', 'ldap.bind_dn')
AUTH_LDAP_BIND_PASSWORD = get_setting(
'INVENTREE_LDAP_BIND_PASSWORD', 'ldap.bind_password'
)
AUTH_LDAP_USER_SEARCH = django_auth_ldap.config.LDAPSearch(
get_setting('INVENTREE_LDAP_SEARCH_BASE_DN', 'ldap.search_base_dn'),
ldap.SCOPE_SUBTREE,
str(
get_setting(
'INVENTREE_LDAP_SEARCH_FILTER_STR',
'ldap.search_filter_str',
'(uid= %(user)s)',
)
),
)
AUTH_LDAP_USER_DN_TEMPLATE = get_setting(
'INVENTREE_LDAP_USER_DN_TEMPLATE', 'ldap.user_dn_template'
)
AUTH_LDAP_USER_ATTR_MAP = get_setting(
'INVENTREE_LDAP_USER_ATTR_MAP',
'ldap.user_attr_map',
{'first_name': 'givenName', 'last_name': 'sn', 'email': 'mail'},
dict,
)
AUTH_LDAP_ALWAYS_UPDATE_USER = get_boolean_setting(
'INVENTREE_LDAP_ALWAYS_UPDATE_USER', 'ldap.always_update_user', True
)
AUTH_LDAP_CACHE_TIMEOUT = get_setting(
'INVENTREE_LDAP_CACHE_TIMEOUT', 'ldap.cache_timeout', 3600, int
)
AUTH_LDAP_MIRROR_GROUPS = get_boolean_setting(
'INVENTREE_LDAP_MIRROR_GROUPS', 'ldap.mirror_groups', False
)
AUTH_LDAP_GROUP_OBJECT_CLASS = get_setting(
'INVENTREE_LDAP_GROUP_OBJECT_CLASS',
'ldap.group_object_class',
'groupOfUniqueNames',
str,
)
AUTH_LDAP_GROUP_SEARCH = django_auth_ldap.config.LDAPSearch(
get_setting('INVENTREE_LDAP_GROUP_SEARCH', 'ldap.group_search'),
ldap.SCOPE_SUBTREE,
f'(objectClass={AUTH_LDAP_GROUP_OBJECT_CLASS})',
)
AUTH_LDAP_GROUP_TYPE_CLASS = get_setting(
'INVENTREE_LDAP_GROUP_TYPE_CLASS',
'ldap.group_type_class',
'GroupOfUniqueNamesType',
str,
)
AUTH_LDAP_GROUP_TYPE_CLASS_ARGS = get_setting(
'INVENTREE_LDAP_GROUP_TYPE_CLASS_ARGS', 'ldap.group_type_class_args', [], list
)
AUTH_LDAP_GROUP_TYPE_CLASS_KWARGS = get_setting(
'INVENTREE_LDAP_GROUP_TYPE_CLASS_KWARGS',
'ldap.group_type_class_kwargs',
{'name_attr': 'cn'},
dict,
)
AUTH_LDAP_GROUP_TYPE = getattr(django_auth_ldap.config, AUTH_LDAP_GROUP_TYPE_CLASS)(
*AUTH_LDAP_GROUP_TYPE_CLASS_ARGS, **AUTH_LDAP_GROUP_TYPE_CLASS_KWARGS
)
AUTH_LDAP_REQUIRE_GROUP = get_setting(
'INVENTREE_LDAP_REQUIRE_GROUP', 'ldap.require_group'
)
AUTH_LDAP_DENY_GROUP = get_setting('INVENTREE_LDAP_DENY_GROUP', 'ldap.deny_group')
AUTH_LDAP_USER_FLAGS_BY_GROUP = get_setting(
'INVENTREE_LDAP_USER_FLAGS_BY_GROUP',
'ldap.user_flags_by_group',
default_value=None,
typecast=dict,
)
AUTH_LDAP_FIND_GROUP_PERMS = True
# Allow secure http developer server in debug mode
if DEBUG:
@ -637,107 +553,11 @@ Configure the database backend based on the user-specified values.
logger.debug('Configuring database backend:')
# Extract database configuration from the config.yaml file
db_config = CONFIG.get('database', None) if CONFIG else None
# Load database configuration from the config file and environment variables
database = db_backend.get_db_backend()
DB_ENGINE = database['ENGINE']
if not db_config:
db_config = {}
# Environment variables take preference over config file!
db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
for key in db_keys:
# First, check the environment variables
env_key = f'INVENTREE_DB_{key}'
env_var = os.environ.get(env_key, None)
if env_var:
# Make use PORT is int
if key == 'PORT':
try:
env_var = int(env_var)
except ValueError:
logger.exception('Invalid number for %s: %s', env_key, env_var)
# Override configuration value
db_config[key] = env_var
# Check that required database configuration options are specified
required_keys = ['ENGINE', 'NAME']
# Ensure all database keys are upper case
db_config = {key.upper(): value for key, value in db_config.items()}
for key in required_keys:
if key not in db_config: # pragma: no cover
error_msg = (
f'Missing required database configuration value `INVENTREE_DB_{key}`'
)
logger.error(error_msg)
print('Error: ' + error_msg)
sys.exit(-1)
"""
Special considerations for the database 'ENGINE' setting.
It can be specified in config.yaml (or envvar) as either (for example):
- sqlite3
- django.db.backends.sqlite3
- django.db.backends.postgresql
"""
DB_ENGINE = db_config['ENGINE'].lower()
# Correct common misspelling
if DB_ENGINE == 'sqlite':
DB_ENGINE = 'sqlite3' # pragma: no cover
if DB_ENGINE in ['sqlite3', 'postgresql', 'mysql']:
# Prepend the required python module string
DB_ENGINE = f'django.db.backends.{DB_ENGINE}'
db_config['ENGINE'] = DB_ENGINE
db_name = db_config['NAME']
db_host = db_config.get('HOST', "''")
if 'sqlite' in DB_ENGINE:
db_name = str(Path(db_name).resolve())
db_config['NAME'] = db_name
logger.info('DB_ENGINE: %s', DB_ENGINE)
logger.info('DB_NAME: %s', db_name)
logger.info('DB_HOST: %s', db_host)
"""
In addition to base-level database configuration, we may wish to specify specific options to the database backend
Ref: https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-OPTIONS
"""
# 'OPTIONS' or 'options' can be specified in config.yaml
# Set useful sensible timeouts for a transactional webserver to communicate
# with its database server, that is, if the webserver is having issues
# connecting to the database server (such as a replica failover) don't sit and
# wait for possibly an hour or more, just tell the client something went wrong
# and let the client retry when they want to.
db_options = db_config.get('OPTIONS', db_config.get('options'))
if db_options is None:
db_options = {}
# Set database-specific options
db_backend.set_db_options(DB_ENGINE, db_options)
# Provide OPTIONS dict back to the database configuration dict
db_config['OPTIONS'] = db_options
# Set testing options for the database
db_config['TEST'] = {'CHARSET': 'utf8'}
# Set collation option for mysql test database
if 'mysql' in DB_ENGINE:
db_config['TEST']['COLLATION'] = 'utf8_general_ci' # pragma: no cover
DATABASES = {'default': db_config}
DATABASES = {'default': database}
# login settings
REMOTE_LOGIN = get_boolean_setting(
@ -771,123 +591,28 @@ if SENTRY_ENABLED and SENTRY_DSN and not TESTING: # pragma: no cover
init_sentry(SENTRY_DSN, SENTRY_SAMPLE_RATE, inventree_tags)
# OpenTelemetry tracing
TRACING_ENABLED = get_boolean_setting(
'INVENTREE_TRACING_ENABLED', 'tracing.enabled', False
TRACING_ENABLED = (
get_boolean_setting('INVENTREE_TRACING_ENABLED', 'tracing.enabled', False)
and not isRunningBackup()
)
TRACING_DETAILS: Optional[dict] = None
if TRACING_ENABLED and not isRunningBackup(): # pragma: no cover
from InvenTree.tracing import setup_instruments, setup_tracing
_t_endpoint = get_setting('INVENTREE_TRACING_ENDPOINT', 'tracing.endpoint', None)
_t_headers = get_setting('INVENTREE_TRACING_HEADERS', 'tracing.headers', None, dict)
if _t_headers is None:
_t_headers = {}
if _t_endpoint:
logger.info('OpenTelemetry tracing enabled')
TRACING_DETAILS = (
TRACING_DETAILS
if TRACING_DETAILS
else {
'endpoint': _t_endpoint,
'headers': _t_headers,
'resources_input': {
**{'inventree.env.' + k: v for k, v in inventree_tags.items()},
**get_setting(
'INVENTREE_TRACING_RESOURCES',
'tracing.resources',
default_value=None,
typecast=dict,
),
},
'console': get_boolean_setting(
'INVENTREE_TRACING_CONSOLE', 'tracing.console', False
),
'auth': get_setting(
'INVENTREE_TRACING_AUTH',
'tracing.auth',
default_value=None,
typecast=dict,
),
'is_http': get_setting(
'INVENTREE_TRACING_IS_HTTP', 'tracing.is_http', True
),
'append_http': get_boolean_setting(
'INVENTREE_TRACING_APPEND_HTTP', 'tracing.append_http', True
),
}
)
# Run tracing/logging instrumentation
setup_tracing(**TRACING_DETAILS)
setup_instruments(DB_ENGINE)
else:
logger.warning('OpenTelemetry tracing not enabled because endpoint is not set')
# endregion
TRACING_DETAILS: Optional[dict] = tracing.configure_tracing(
DB_ENGINE, TRACING_ENABLED, inventree_tags
)
# Cache configuration
GLOBAL_CACHE_ENABLED = is_global_cache_enabled()
CACHES = {'default': get_cache_config(GLOBAL_CACHE_ENABLED)}
BACKGROUND_WORKER_TIMEOUT = int(
get_setting('INVENTREE_BACKGROUND_TIMEOUT', 'background.timeout', 90)
# Background task processing with django-q
Q_CLUSTER = worker.get_worker_config(
DB_ENGINE,
global_cache=GLOBAL_CACHE_ENABLED,
sentry_dsn=SENTRY_DSN if SENTRY_ENABLED and SENTRY_DSN else None,
debug=DEBUG,
)
# Set the retry time for background workers to be slightly longer than the worker timeout, to ensure that workers have time to timeout before being retried
BACKGROUND_WORKER_RETRY = max(
int(get_setting('INVENTREE_BACKGROUND_RETRY', 'background.retry', 300)),
BACKGROUND_WORKER_TIMEOUT + 120,
)
# Prevent running multiple background workers if global cache is disabled
# This is to prevent scheduling conflicts due to the lack of a shared cache
BACKGROUND_WORKER_COUNT = (
int(get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4))
if GLOBAL_CACHE_ENABLED
else 1
)
# If running with SQLite, limit background worker threads to 1 to prevent database locking issues
if 'sqlite' in DB_ENGINE:
BACKGROUND_WORKER_COUNT = 1
BACKGROUND_WORKER_ATTEMPTS = int(
get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5)
)
# django-q background worker configuration
Q_CLUSTER = {
'name': 'InvenTree',
'label': 'Background Tasks',
'workers': BACKGROUND_WORKER_COUNT,
'timeout': BACKGROUND_WORKER_TIMEOUT,
'retry': BACKGROUND_WORKER_RETRY,
'max_attempts': BACKGROUND_WORKER_ATTEMPTS,
'save_limit': 1000,
'queue_limit': 50,
'catch_up': False,
'bulk': 10,
'orm': 'default',
'cache': 'default',
'sync': False,
'poll': 1.5,
}
# Configure django-q sentry integration
if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover
Q_CLUSTER['error_reporter'] = {'sentry': {'dsn': SENTRY_DSN}}
if GLOBAL_CACHE_ENABLED: # pragma: no cover
# If using external redis cache, make the cache the broker for Django Q
# as well
Q_CLUSTER['django_redis'] = 'worker'
SILENCED_SYSTEM_CHECKS = ['templates.E003', 'templates.W003']
# Password validation
@ -961,10 +686,10 @@ INTERNAL_EMAIL_BACKEND = get_setting(
)
# SMTP backend
EMAIL_HOST = get_setting('INVENTREE_EMAIL_HOST', 'email.host', '')
EMAIL_HOST = get_setting('INVENTREE_EMAIL_HOST', 'email.host')
EMAIL_PORT = get_setting('INVENTREE_EMAIL_PORT', 'email.port', 25, typecast=int)
EMAIL_HOST_USER = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '')
EMAIL_HOST_PASSWORD = get_setting('INVENTREE_EMAIL_PASSWORD', 'email.password', '')
EMAIL_HOST_USER = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username')
EMAIL_HOST_PASSWORD = get_setting('INVENTREE_EMAIL_PASSWORD', 'email.password')
EMAIL_USE_TLS = get_boolean_setting('INVENTREE_EMAIL_TLS', 'email.tls', False)
EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False)
# Anymail
@ -1135,16 +860,14 @@ LANGUAGE_COOKIE_SAMESITE = COOKIE_MODE
- Otherwise, use the value specified in the configuration file (or env var)
"""
COOKIE_SECURE = (
False
if DEBUG
else (
SESSION_COOKIE_SAMESITE == 'None'
or get_boolean_setting(
'INVENTREE_SESSION_COOKIE_SECURE', 'cookie.secure', False
)
)
get_boolean_setting('INVENTREE_SESSION_COOKIE_SECURE', 'cookie.secure', False)
or SESSION_COOKIE_SAMESITE == 'None'
)
# Override COOKIE_SECURE value in DEBUG mode
if DEBUG:
COOKIE_SECURE = False
CSRF_COOKIE_SECURE = COOKIE_SECURE
SESSION_COOKIE_SECURE = COOKIE_SECURE
LANGUAGE_COOKIE_SECURE = COOKIE_SECURE

View File

@ -29,7 +29,6 @@ from maintenance_mode.core import (
from opentelemetry import trace
from common.settings import get_global_setting, set_global_setting
from InvenTree.config import get_setting
from plugin import registry
from .version import isInvenTreeUpToDate
@ -822,7 +821,7 @@ def check_for_migrations(force: bool = False, reload_registry: bool = True) -> b
set_pending_migrations(n)
# Test if auto-updates are enabled
if not force and not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
if not force and not settings.AUTO_UPDATE:
logger.info('Auto-update is disabled - skipping migrations')
return False

View File

@ -8,6 +8,7 @@ from rest_framework.serializers import SerializerMethodField
import InvenTree.serializers
from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin
from InvenTree.serializers import OptionalField
from InvenTree.unit_test import InvenTreeAPITestCase
from InvenTree.urls import backendpatterns
@ -25,21 +26,25 @@ class SampleSerializer(
fields = ['field_a', 'field_b', 'field_c', 'field_d', 'field_e', 'id']
field_a = SerializerMethodField(method_name='sample')
field_b = InvenTree.serializers.enable_filter(
InvenTree.serializers.FilterableSerializerMethodField(method_name='sample')
field_b = OptionalField(
serializer_class=SerializerMethodField,
serializer_kwargs={'method_name': 'sample'},
)
field_c = InvenTree.serializers.enable_filter(
InvenTree.serializers.FilterableSerializerMethodField(method_name='sample'),
True,
field_c = OptionalField(
serializer_class=SerializerMethodField,
serializer_kwargs={'method_name': 'sample'},
default_include=True,
filter_name='crazy_name',
)
field_d = InvenTree.serializers.enable_filter(
InvenTree.serializers.FilterableSerializerMethodField(method_name='sample'),
True,
field_d = OptionalField(
serializer_class=SerializerMethodField,
serializer_kwargs={'method_name': 'sample'},
default_include=True,
filter_name='crazy_name',
)
field_e = InvenTree.serializers.enable_filter(
InvenTree.serializers.FilterableSerializerMethodField(method_name='sample'),
field_e = OptionalField(
serializer_class=SerializerMethodField,
serializer_kwargs={'method_name': 'sample'},
filter_name='field_e',
filter_by_query=False,
)
@ -106,110 +111,3 @@ class FilteredSerializers(InvenTreeAPITestCase):
self.assertContains(response, 'field_c')
self.assertContains(response, 'field_d')
self.assertNotContains(response, 'field_e')
def test_failiure_enable_filter(self):
"""Test sanity check for enable_filter."""
# Allowed usage
field_b = InvenTree.serializers.enable_filter( # noqa: F841
InvenTree.serializers.FilterableSerializerMethodField(method_name='sample')
)
# Disallowed usage
with self.assertRaises(Exception) as cm:
field_a = InvenTree.serializers.enable_filter( # noqa: F841
SerializerMethodField(method_name='sample')
)
self.assertIn(
'INVE-I2: `enable_filter` can only be applied to serializer fields',
str(cm.exception),
)
def test_failiure_FilterableSerializerMixin(self):
"""Test failure case for FilteredSerializerMixin."""
class BadSerializer(
InvenTree.serializers.FilterableSerializerMixin,
InvenTree.serializers.InvenTreeModelSerializer,
):
"""Bad serializer for testing FilterableSerializerMixin."""
class Meta:
"""Meta options."""
model = User
fields = ['field_a', 'id']
field_a = SerializerMethodField(method_name='sample')
def sample(self, obj):
"""Sample method field."""
return 'sample' # pragma: no cover
with self.assertRaises(Exception) as cm:
_ = BadSerializer()
self.assertIn(
'INVE-I2: No filter targets found in fields, remove `PathScopedMixin`',
str(cm.exception),
)
# Test override
BadSerializer.no_filters = True
_ = BadSerializer()
self.assertTrue(True) # Dummy assertion to ensure we reach here
def test_failure_OutputOptionsMixin(self):
"""Test failure case for OutputOptionsMixin."""
class BadSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Sample serializer."""
class Meta:
"""Meta options."""
model = User
fields = ['id']
field_a = SerializerMethodField(method_name='sample')
# Bad implementation of OutputOptionsMixin
with self.assertRaises(Exception) as cm:
class BadList(OutputOptionsMixin, ListCreateAPI):
"""Bad list endpoint for testing OutputOptionsMixin."""
serializer_class = BadSerializer
queryset = User.objects.all()
permission_classes = []
self.assertTrue(True)
_ = BadList() # this should raise an exception
self.assertEqual(
str(cm.exception),
'INVE-I2: `OutputOptionsMixin` can only be used with serializers that contain the `FilterableSerializerMixin` mixin',
)
# More creative bad implementation
with self.assertRaises(Exception) as cm:
class BadList(OutputOptionsMixin, ListCreateAPI):
"""Bad list endpoint for testing OutputOptionsMixin."""
queryset = User.objects.all()
permission_classes = []
def get_serializer(self, *args, **kwargs):
"""Get serializer override."""
self.serializer_class = BadSerializer
return super().get_serializer(*args, **kwargs)
view = BadList()
self.assertTrue(True)
# mock some stuff to allow get_serializer to run
view.request = self.client.request()
view.format_kwarg = {}
view.get_serializer() # this should raise an exception
self.assertEqual(
str(cm.exception),
'INVE-I2: `OutputOptionsMixin` can only be used with serializers that contain the `FilterableSerializerMixin` mixin',
)

View File

@ -692,13 +692,16 @@ class TestHelpers(TestCase):
self.assertFalse(helpers.isNull(s))
def testStaticUrl(self):
"""Test static url helpers."""
"""Test static URL helpers."""
self.assertEqual(helpers.getStaticUrl('test.jpg'), '/static/test.jpg')
self.assertEqual(helpers.getBlankImage(), '/static/img/blank_image.png')
self.assertEqual(
helpers.getBlankThumbnail(), '/static/img/blank_image.thumbnail.png'
)
self.assertFalse(helpers.checkStaticFile('dummy', 'dir', 'test.jpg'))
self.assertTrue(helpers.checkStaticFile('img', 'blank_image.png'))
def testMediaUrl(self):
"""Test getMediaUrl."""
# Str should not work

View File

@ -17,7 +17,7 @@ from django.conf import settings
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = '1.3.0 dev'
INVENTREE_SW_VERSION = '1.4.0 dev'
# Minimum supported Python version
MIN_PYTHON_VERSION = (3, 11)

View File

@ -35,7 +35,7 @@ from InvenTree.serializers import (
InvenTreeDecimalField,
InvenTreeModelSerializer,
NotesFieldMixin,
enable_filter,
OptionalField,
)
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
@ -116,9 +116,10 @@ class BuildSerializer(
status_text = serializers.CharField(source='get_status_display', read_only=True)
part_detail = enable_filter(
part_serializers.PartBriefSerializer(source='part', many=False, read_only=True),
True,
part_detail = OptionalField(
serializer_class=part_serializers.PartBriefSerializer,
serializer_kwargs={'source': 'part', 'many': False, 'read_only': True},
default_include=True,
prefetch_fields=['part', 'part__category', 'part__pricing_data'],
)
@ -132,16 +133,22 @@ class BuildSerializer(
overdue = serializers.BooleanField(read_only=True, default=False)
issued_by_detail = enable_filter(
UserSerializer(source='issued_by', read_only=True),
True,
issued_by_detail = OptionalField(
serializer_class=UserSerializer,
serializer_kwargs={'source': 'issued_by', 'read_only': True},
default_include=True,
filter_name='user_detail',
prefetch_fields=['issued_by'],
)
responsible_detail = enable_filter(
OwnerSerializer(source='responsible', read_only=True, allow_null=True),
True,
responsible_detail = OptionalField(
serializer_class=OwnerSerializer,
serializer_kwargs={
'source': 'responsible',
'read_only': True,
'allow_null': True,
},
default_include=True,
filter_name='user_detail',
prefetch_fields=['responsible'],
)
@ -1200,31 +1207,33 @@ class BuildItemSerializer(
)
# Extra (optional) detail fields
part_detail = enable_filter(
part_serializers.PartBriefSerializer(
label=_('Part'),
source='stock_item.part',
many=False,
read_only=True,
allow_null=True,
pricing=False,
),
True,
part_detail = OptionalField(
serializer_class=part_serializers.PartBriefSerializer,
serializer_kwargs={
'label': _('Part'),
'source': 'stock_item.part',
'many': False,
'read_only': True,
'allow_null': True,
'pricing': False,
},
default_include=True,
prefetch_fields=['stock_item__part'],
)
stock_item_detail = enable_filter(
StockItemSerializer(
source='stock_item',
read_only=True,
allow_null=True,
label=_('Stock Item'),
part_detail=False,
location_detail=False,
supplier_part_detail=False,
path_detail=False,
),
True,
stock_item_detail = OptionalField(
serializer_class=StockItemSerializer,
serializer_kwargs={
'source': 'stock_item',
'read_only': True,
'allow_null': True,
'label': _('Stock Item'),
'part_detail': False,
'location_detail': False,
'supplier_part_detail': False,
'path_detail': False,
},
default_include=True,
filter_name='stock_detail',
prefetch_fields=[
'stock_item',
@ -1234,18 +1243,19 @@ class BuildItemSerializer(
],
)
install_into_detail = enable_filter(
StockItemSerializer(
source='install_into',
read_only=True,
allow_null=True,
label=_('Install Into'),
part_detail=False,
location_detail=False,
supplier_part_detail=False,
path_detail=False,
),
False,
install_into_detail = OptionalField(
serializer_class=StockItemSerializer,
serializer_kwargs={
'source': 'install_into',
'read_only': True,
'allow_null': True,
'label': _('Install Into'),
'part_detail': False,
'location_detail': False,
'supplier_part_detail': False,
'path_detail': False,
},
default_include=False,
prefetch_fields=['install_into', 'install_into__part'],
)
@ -1253,26 +1263,28 @@ class BuildItemSerializer(
label=_('Location'), source='stock_item.location', many=False, read_only=True
)
location_detail = enable_filter(
LocationBriefSerializer(
label=_('Location'),
source='stock_item.location',
read_only=True,
allow_null=True,
),
True,
location_detail = OptionalField(
serializer_class=LocationBriefSerializer,
serializer_kwargs={
'label': _('Location'),
'source': 'stock_item.location',
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['stock_item__location'],
)
build_detail = enable_filter(
BuildSerializer(
label=_('Build'),
source='build_line.build',
many=False,
read_only=True,
allow_null=True,
),
True,
build_detail = OptionalField(
serializer_class=BuildSerializer,
serializer_kwargs={
'label': _('Build'),
'source': 'build_line.build',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=[
'build_line__build',
'build_line__build__part',
@ -1283,16 +1295,17 @@ class BuildItemSerializer(
],
)
supplier_part_detail = enable_filter(
company.serializers.SupplierPartSerializer(
label=_('Supplier Part'),
source='stock_item.supplier_part',
many=False,
read_only=True,
allow_null=True,
brief=True,
),
False,
supplier_part_detail = OptionalField(
serializer_class=company.serializers.SupplierPartSerializer,
serializer_kwargs={
'label': _('Supplier Part'),
'source': 'stock_item.supplier_part',
'many': False,
'read_only': True,
'allow_null': True,
'brief': True,
},
default_include=False,
prefetch_fields=[
'stock_item__supplier_part',
'stock_item__supplier_part__supplier',
@ -1382,11 +1395,15 @@ class BuildLineSerializer(
read_only=True,
)
allocations = enable_filter(
BuildItemSerializer(
many=True, read_only=True, allow_null=True, build_detail=False
),
True,
allocations = OptionalField(
serializer_class=BuildItemSerializer,
serializer_kwargs={
'many': True,
'read_only': True,
'allow_null': True,
'build_detail': False,
},
default_include=True,
prefetch_fields=[
'allocations',
'allocations__stock_item',
@ -1427,73 +1444,79 @@ class BuildLineSerializer(
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
# Foreign key fields
bom_item_detail = enable_filter(
part_serializers.BomItemSerializer(
label=_('BOM Item'),
source='bom_item',
many=False,
read_only=True,
allow_null=True,
pricing=False,
substitutes=False,
sub_part_detail=False,
part_detail=False,
can_build=False,
),
False,
bom_item_detail = OptionalField(
serializer_class=part_serializers.BomItemSerializer,
serializer_kwargs={
'label': _('BOM Item'),
'source': 'bom_item',
'many': False,
'read_only': True,
'allow_null': True,
'pricing': False,
'substitutes': False,
'sub_part_detail': False,
'part_detail': False,
'can_build': False,
},
default_include=False,
prefetch_fields=['bom_item'],
)
assembly_detail = enable_filter(
part_serializers.PartBriefSerializer(
label=_('Assembly'),
source='bom_item.part',
many=False,
read_only=True,
allow_null=True,
pricing=False,
),
False,
assembly_detail = OptionalField(
serializer_class=part_serializers.PartBriefSerializer,
serializer_kwargs={
'label': _('Assembly'),
'source': 'bom_item.part',
'many': False,
'read_only': True,
'allow_null': True,
'pricing': False,
},
default_include=False,
prefetch_fields=['bom_item__part', 'bom_item__part__pricing_data'],
)
part_detail = enable_filter(
part_serializers.PartBriefSerializer(
label=_('Part'),
source='bom_item.sub_part',
many=False,
read_only=True,
allow_null=True,
pricing=False,
),
False,
part_detail = OptionalField(
serializer_class=part_serializers.PartBriefSerializer,
serializer_kwargs={
'label': _('Part'),
'source': 'bom_item.sub_part',
'many': False,
'read_only': True,
'allow_null': True,
'pricing': False,
},
default_include=False,
prefetch_fields=['bom_item__sub_part', 'bom_item__sub_part__pricing_data'],
)
category_detail = enable_filter(
part_serializers.CategorySerializer(
label=_('Category'),
source='bom_item.sub_part.category',
many=False,
read_only=True,
allow_null=True,
),
False,
category_detail = OptionalField(
serializer_class=part_serializers.CategorySerializer,
serializer_kwargs={
'label': _('Category'),
'source': 'bom_item.sub_part.category',
'many': False,
'read_only': True,
'allow_null': True,
'path_detail': False,
},
default_include=False,
prefetch_fields=['bom_item__sub_part__category'],
)
build_detail = enable_filter(
BuildSerializer(
label=_('Build'),
source='build',
many=False,
read_only=True,
allow_null=True,
part_detail=False,
user_detail=False,
project_code_detail=False,
),
True,
build_detail = OptionalField(
serializer_class=BuildSerializer,
serializer_kwargs={
'label': _('Build'),
'source': 'build',
'many': False,
'read_only': True,
'allow_null': True,
'part_detail': False,
'user_detail': False,
'project_code_detail': False,
},
default_include=True,
)
# Annotated (calculated) fields

View File

@ -1162,18 +1162,12 @@ class BuildListTest(BuildAPITest):
data = self.options(self.url, expected_code=200).data
self.assertEqual(data['name'], 'Build List')
actions = data['actions']['POST']
actions = data['actions']['GET']
for field_name in [
'pk',
'title',
'part',
'part_detail',
'project_code',
'project_code_detail',
'quantity',
]:
for field_name in ['pk', 'title', 'part', 'project_code', 'quantity']:
# Fields should exist in both GET and POST actions
self.assertIn(field_name, actions)
self.assertIn(field_name, data['actions']['POST'])
# Specific checks for certain fields
for field_name in ['part', 'project_code', 'take_from']:

View File

@ -32,6 +32,24 @@ class ParameterAdmin(admin.ModelAdmin):
search_fields = ('template__name', 'data', 'note')
class SelectionListEntryInlineAdmin(admin.StackedInline):
"""Inline admin class for the SelectionListEntry model."""
model = common.models.SelectionListEntry
extra = 0
@admin.register(common.models.SelectionList)
class SelectionListAdmin(admin.ModelAdmin):
"""Admin interface for SelectionList objects."""
list_display = ('name', 'description', 'active', 'locked')
search_fields = ('name', 'description')
list_filter = ('active', 'locked')
inlines = [SelectionListEntryInlineAdmin]
@admin.register(common.models.Attachment)
class AttachmentAdmin(admin.ModelAdmin):
"""Admin interface for Attachment objects."""

View File

@ -20,11 +20,17 @@ import django_q.models
import django_q.tasks
from django_filters.rest_framework.filterset import FilterSet
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
extend_schema,
extend_schema_view,
)
from error_report.models import Error
from opentelemetry import trace
from pint._typing import UnitLike
from rest_framework import generics, serializers
from rest_framework import serializers, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response
@ -44,6 +50,7 @@ from generic.states.api import urlpattern as generic_states_api_urls
from InvenTree.api import (
BulkCreateMixin,
BulkDeleteMixin,
BulkDeleteViewsetMixin,
GenericMetadataView,
SimpleGenericMetadataView,
meta_path,
@ -51,6 +58,11 @@ from InvenTree.api import (
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
from InvenTree.helpers import inheritors, str2bool
from InvenTree.helpers_api import (
InvenTreeApiRouter,
RetrieveDestroyModelViewSet,
RetrieveUpdateDestroyModelViewSet,
)
from InvenTree.helpers_email import send_email
from InvenTree.mixins import (
CreateAPI,
@ -58,7 +70,6 @@ from InvenTree.mixins import (
ListCreateAPI,
OutputOptionsMixin,
RetrieveAPI,
RetrieveDestroyAPI,
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
)
@ -71,6 +82,10 @@ from InvenTree.permissions import (
IsSuperuserOrSuperScope,
UserSettingsPermissionsOrScope,
)
from InvenTree.serializers import EmptySerializer
admin_router = InvenTreeApiRouter()
common_router = InvenTreeApiRouter()
class CsrfExemptMixin:
@ -155,14 +170,18 @@ class WebhookView(CsrfExemptMixin, APIView):
raise NotFound()
class CurrencyExchangeView(APIView):
"""API endpoint for displaying currency information."""
class CurrencyViewSet(viewsets.GenericViewSet):
"""Viewset for currency exchange information."""
permission_classes = [IsAuthenticatedOrReadScope]
serializer_class = None
serializer_class = EmptySerializer
@extend_schema(responses={200: common.serializers.CurrencyExchangeSerializer})
def get(self, request, fmt=None):
@action(
detail=False,
methods=['get'],
serializer_class=common.serializers.CurrencyExchangeSerializer,
)
def exchange(self, request, fmt=None):
"""Return information on available currency conversions."""
# Extract a list of all available rates
try:
@ -195,17 +214,12 @@ class CurrencyExchangeView(APIView):
return Response(response)
class CurrencyRefreshView(APIView):
"""API endpoint for manually refreshing currency exchange rates.
User must be a 'staff' user to access this endpoint
"""
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
serializer_class = None
def post(self, request, *args, **kwargs):
@action(
detail=False,
methods=['post'],
permission_classes=[IsAuthenticatedOrReadScope, IsAdminUser],
)
def refresh(self, request, *args, **kwargs):
"""Performing a POST request will update currency exchange rates."""
from InvenTree.tasks import update_exchange_rates
@ -214,6 +228,9 @@ class CurrencyRefreshView(APIView):
return Response({'success': 'Exchange rates updated'})
common_router.register('currency', CurrencyViewSet, basename='api-currency')
class SettingsList(ListAPI):
"""Generic ListView for settings.
@ -325,13 +342,23 @@ class UserSettingsDetail(RetrieveUpdateAPI):
)
class NotificationMessageMixin:
"""Generic mixin for NotificationMessage."""
class NotificationMessageViewSet(
BulkDeleteViewsetMixin, RetrieveUpdateDestroyModelViewSet
):
"""Notifications for the current user.
- User can only view / delete their own notification objects
"""
queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationMessageSerializer
permission_classes = [UserSettingsPermissionsOrScope]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['category', 'name', 'read', 'creation']
search_fields = ['name', 'message']
filterset_fields = ['category', 'read']
def get_queryset(self):
"""Return prefetched queryset."""
queryset = (
@ -348,20 +375,6 @@ class NotificationMessageMixin:
return queryset
class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
"""List view for all notifications of the current user."""
permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['category', 'name', 'read', 'creation']
search_fields = ['name', 'message']
filterset_fields = ['category', 'read']
def filter_queryset(self, queryset):
"""Only list notifications which apply to the current user."""
try:
@ -382,18 +395,10 @@ class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
queryset = queryset.filter(user=request.user)
return queryset
class NotificationDetail(NotificationMessageMixin, RetrieveUpdateDestroyAPI):
"""Detail view for an individual notification object.
- User can only view / delete their own notification objects
"""
class NotificationReadAll(NotificationMessageMixin, RetrieveAPI):
"""API endpoint to mark all notifications as read."""
def get(self, request, *args, **kwargs):
@action(
detail=False, methods=['post'], permission_classes=[IsAuthenticatedOrReadScope]
)
def readall(self, request, *args, **kwargs):
"""Set all messages for the current user as read."""
try:
self.queryset.filter(user=request.user, read=False).update(read=True)
@ -404,47 +409,50 @@ class NotificationReadAll(NotificationMessageMixin, RetrieveAPI):
)
class NewsFeedMixin:
"""Generic mixin for NewsFeedEntry."""
common_router.register(
'notifications', NotificationMessageViewSet, basename='api-notifications'
)
class NewsFeedViewSet(BulkDeleteViewsetMixin, RetrieveUpdateDestroyModelViewSet):
"""Newsfeed from the official inventree.org website."""
queryset = common.models.NewsFeedEntry.objects.all()
serializer_class = common.serializers.NewsFeedEntrySerializer
permission_classes = [IsAdminOrAdminScope]
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
"""List view for all news items."""
filter_backends = ORDER_FILTER
ordering = '-published'
ordering_fields = ['published', 'author', 'read']
filterset_fields = ['read']
class NewsFeedEntryDetail(NewsFeedMixin, RetrieveUpdateDestroyAPI):
"""Detail view for an individual news feed object."""
common_router.register('news', NewsFeedViewSet, basename='api-news')
class ConfigList(ListAPI):
"""List view for all accessed configurations."""
@extend_schema_view(
retrieve=extend_schema(
parameters=[
OpenApiParameter(
name='key',
description='Unique identifier for this configuration',
required=True,
location=OpenApiParameter.PATH,
)
]
)
)
class ConfigViewSet(viewsets.ReadOnlyModelViewSet):
"""All accessed/in-use configurations."""
queryset = CONFIG_LOOKUPS
serializer_class = common.serializers.ConfigSerializer
permission_classes = [IsSuperuserOrSuperScope]
lookup_field = 'key'
# Specifically disable pagination for this view
pagination_class = None
class ConfigDetail(RetrieveAPI):
"""Detail view for an individual configuration."""
serializer_class = common.serializers.ConfigSerializer
permission_classes = [IsSuperuserOrSuperScope]
def get_object(self):
"""Attempt to find a config object with the provided key."""
key = self.kwargs['key']
@ -454,6 +462,9 @@ class ConfigDetail(RetrieveAPI):
return {key: value}
admin_router.register('config', ConfigViewSet, basename='api-config')
class NotesImageList(ListCreateAPI):
"""List view for all notes images."""
@ -493,7 +504,7 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [IsStaffOrReadOnlyScope]
class CustomUnitList(DataExportViewMixin, ListCreateAPI):
class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet):
"""List view for custom units."""
queryset = common.models.CustomUnit.objects.all()
@ -501,22 +512,12 @@ class CustomUnitList(DataExportViewMixin, ListCreateAPI):
permission_classes = [IsStaffOrReadOnlyScope]
filter_backends = SEARCH_ORDER_FILTER
class CustomUnitDetail(RetrieveUpdateDestroyAPI):
"""Detail view for a particular custom unit."""
queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [IsStaffOrReadOnlyScope]
class AllUnitList(RetrieveAPI):
"""List of all defined units."""
serializer_class = common.serializers.AllUnitListResponseSerializer
permission_classes = [IsStaffOrReadOnlyScope]
def get(self, request, *args, **kwargs):
@action(
detail=False,
methods=['get'],
serializer_class=common.serializers.AllUnitListResponseSerializer,
)
def all(self, request, *args, **kwargs):
"""Return a list of all available units."""
reg = InvenTree.conversion.get_unit_registry()
all_units = {k: self.get_unit(reg, k) for k in reg}
@ -540,7 +541,10 @@ class AllUnitList(RetrieveAPI):
}
class ErrorMessageList(BulkDeleteMixin, ListAPI):
common_router.register('units', CustomUnitViewset, basename='api-custom-unit')
class ErrorMessageViewSet(BulkDeleteViewsetMixin, RetrieveUpdateDestroyModelViewSet):
"""List view for server error messages."""
queryset = Error.objects.all()
@ -556,12 +560,7 @@ class ErrorMessageList(BulkDeleteMixin, ListAPI):
search_fields = ['info', 'data']
class ErrorMessageDetail(RetrieveUpdateDestroyAPI):
"""Detail view for a single error message."""
queryset = Error.objects.all()
serializer_class = common.serializers.ErrorMessageSerializer
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
common_router.register('error-report', ErrorMessageViewSet, basename='api-error')
class BackgroundTaskDetail(APIView):
@ -1166,18 +1165,30 @@ class EntryMixin:
class SelectionEntryList(EntryMixin, ListCreateAPI):
"""List view for SelectionEntry objects."""
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['list', 'label', 'active']
search_fields = ['label', 'description']
filterset_fields = ['active', 'value', 'list']
class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a SelectionEntry object."""
class DataOutputEndpointMixin:
class DataOutputViewSet(BulkDeleteViewsetMixin, RetrieveDestroyModelViewSet):
"""Mixin class for DataOutput endpoints."""
queryset = common.models.DataOutput.objects.all()
serializer_class = common.serializers.DataOutputSerializer
permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
filterset_fields = ['user']
def get_queryset(self):
"""Return the set of DataOutput objects which the user has permission to view."""
queryset = super().get_queryset()
@ -1195,29 +1206,16 @@ class DataOutputEndpointMixin:
return queryset.filter(user=user)
class DataOutputList(DataOutputEndpointMixin, BulkDeleteMixin, ListAPI):
"""List view for DataOutput objects."""
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
filterset_fields = ['user']
common_router.register('data-output', DataOutputViewSet, basename='api-data-output')
class DataOutputDetail(DataOutputEndpointMixin, generics.DestroyAPIView, RetrieveAPI):
"""Detail view for a DataOutput object."""
class EmailMessageMixin:
"""Mixin class for Email endpoints."""
class EmailViewSet(BulkDeleteViewsetMixin, RetrieveDestroyModelViewSet):
"""Backend E-Mail management for administrative purposes."""
queryset = common.models.EmailMessage.objects.all()
serializer_class = common.serializers.EmailMessageSerializer
permission_classes = [IsSuperuserOrSuperScope]
class EmailMessageList(EmailMessageMixin, BulkDeleteMixin, ListAPI):
"""List view for email objects."""
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = [
'created',
@ -1237,19 +1235,16 @@ class EmailMessageList(EmailMessageMixin, BulkDeleteMixin, ListAPI):
'thread_id_key',
]
class EmailMessageDetail(EmailMessageMixin, RetrieveDestroyAPI):
"""Detail view for an email object."""
class TestEmail(CreateAPI):
"""Send a test email."""
serializer_class = common.serializers.TestEmailSerializer
permission_classes = [IsSuperuserOrSuperScope]
def perform_create(self, serializer):
@action(
detail=False,
methods=['post'],
serializer_class=common.serializers.TestEmailSerializer,
)
def test(self, request):
"""Send a test email."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
delivered, reason = send_email(
@ -1261,6 +1256,10 @@ class TestEmail(CreateAPI):
raise serializers.ValidationError(
detail=f'Failed to send test email: "{reason}"'
) # pragma: no cover
return Response(serializer.data)
admin_router.register('email', EmailViewSet, basename='api-email')
class HealthCheckStatusSerializer(serializers.Serializer):
@ -1483,13 +1482,6 @@ common_api_urls = [
path('', ParameterList.as_view(), name='api-parameter-list'),
]),
),
path(
'error-report/',
include([
path('<int:pk>/', ErrorMessageDetail.as_view(), name='api-error-detail'),
path('', ErrorMessageList.as_view(), name='api-error-list'),
]),
),
# Metadata
path(
'metadata/',
@ -1522,72 +1514,6 @@ common_api_urls = [
path('', ProjectCodeList.as_view(), name='api-project-code-list'),
]),
),
# Custom physical units
path(
'units/',
include([
path(
'<int:pk>/',
include([
path('', CustomUnitDetail.as_view(), name='api-custom-unit-detail')
]),
),
path('all/', AllUnitList.as_view(), name='api-all-unit-list'),
path('', CustomUnitList.as_view(), name='api-custom-unit-list'),
]),
),
# Currencies
path(
'currency/',
include([
path(
'exchange/',
CurrencyExchangeView.as_view(),
name='api-currency-exchange',
),
path(
'refresh/', CurrencyRefreshView.as_view(), name='api-currency-refresh'
),
]),
),
# Notifications
path(
'notifications/',
include([
# Individual purchase order detail URLs
path(
'<int:pk>/',
include([
path(
'',
NotificationDetail.as_view(),
name='api-notifications-detail',
)
]),
),
# Read all
path(
'readall/',
NotificationReadAll.as_view(),
name='api-notifications-readall',
),
# Notification messages list
path('', NotificationList.as_view(), name='api-notifications-list'),
]),
),
# News
path(
'news/',
include([
path(
'<int:pk>/',
include([
path('', NewsFeedEntryDetail.as_view(), name='api-news-detail')
]),
),
path('', NewsFeedEntryList.as_view(), name='api-news-list'),
]),
),
# Flags
path(
'flags/',
@ -1617,16 +1543,6 @@ common_api_urls = [
path('icons/', IconList.as_view(), name='api-icon-list'),
# Selection lists
path('selection/', include(selection_urls)),
# Data output
path(
'data-output/',
include([
path(
'<int:pk>/', DataOutputDetail.as_view(), name='api-data-output-detail'
),
path('', DataOutputList.as_view(), name='api-data-output-list'),
]),
),
# System APIs (related to basic system functions)
path(
'system/',
@ -1647,19 +1563,8 @@ common_api_urls = [
)
]),
),
# Router
path('', include(common_router.urls)),
]
admin_api_urls = [
# Admin
path('config/', ConfigList.as_view(), name='api-config-list'),
path('config/<str:key>/', ConfigDetail.as_view(), name='api-config-detail'),
# Email
path(
'email/',
include([
path('test/', TestEmail.as_view(), name='api-email-test'),
path('<str:pk>/', EmailMessageDetail.as_view(), name='api-email-detail'),
path('', EmailMessageList.as_view(), name='api-email-list'),
]),
),
]
admin_api_urls = admin_router.urls

View File

@ -19,6 +19,9 @@ from django.db.models import (
from django.db.models.query import QuerySet
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from taggit.serializers import TagListSerializerField
import InvenTree.conversion
import InvenTree.helpers
import InvenTree.serializers
@ -325,11 +328,15 @@ def enable_project_code_filter(default: bool = True):
"""
from common.serializers import ProjectCodeSerializer
return InvenTree.serializers.enable_filter(
ProjectCodeSerializer(
source='project_code', many=False, read_only=True, allow_null=True
),
default,
return InvenTree.serializers.OptionalField(
serializer_class=ProjectCodeSerializer,
serializer_kwargs={
'source': 'project_code',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=default,
filter_name='project_code_detail',
prefetch_fields=['project_code'],
)
@ -344,14 +351,15 @@ def enable_project_label_filter(default: bool = True):
If applied, this field will automatically prefetch the 'project_code' relationship.
"""
return InvenTree.serializers.enable_filter(
InvenTree.serializers.FilterableCharField(
source='project_code.code',
read_only=True,
label=_('Project Code Label'),
allow_null=True,
),
default,
return InvenTree.serializers.OptionalField(
serializer_class=serializers.CharField,
serializer_kwargs={
'source': 'project_code.code',
'read_only': True,
'label': _('Project Code Label'),
'allow_null': True,
},
default_include=default,
filter_name='project_code_detail',
prefetch_fields=['project_code'],
)
@ -369,9 +377,15 @@ def enable_parameters_filter():
"""
from common.serializers import ParameterSerializer
return InvenTree.serializers.enable_filter(
ParameterSerializer(many=True, read_only=True, allow_null=True),
False,
return InvenTree.serializers.OptionalField(
serializer_class=ParameterSerializer,
serializer_kwargs={
'many': True,
'read_only': True,
'allow_null': True,
'required': False,
},
default_include=False,
filter_name='parameters',
prefetch_fields=[
'parameters_list',
@ -390,11 +404,10 @@ def enable_tags_filter(default: bool = False):
If applied, this field will automatically prefetch the 'tags' relationship.
"""
from InvenTree.serializers import FilterableTagListField
return InvenTree.serializers.enable_filter(
FilterableTagListField(required=False),
default,
return InvenTree.serializers.OptionalField(
serializer_class=TagListSerializerField,
serializer_kwargs={'required': False},
default_include=default,
filter_name='tags',
prefetch_fields=['tags', 'tagged_items', 'tagged_items__tag'],
)

View File

@ -2320,11 +2320,39 @@ class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeMo
"""Return the API URL associated with the SelectionList model."""
return reverse('api-selectionlist-list')
def get_choices(self):
"""Return the choices for the selection list."""
choices = self.entries.filter(active=True)
def get_choices(self, active: Optional[bool] = True):
"""Return the choices for the selection list.
Arguments:
active: If specified, filter choices by active status
Returns:
List of choice values for this selection list
"""
choices = self.entries.all()
if active is not None:
choices = choices.filter(active=active)
return [c.value for c in choices]
def has_choice(self, value: str, active: Optional[bool] = None):
"""Check if the selection list has a particular choice.
Arguments:
value: The value to check for
active: If specified, filter choices by active status
Returns:
True if the choice exists in the selection list, False otherwise
"""
choices = self.entries.all()
if active is not None:
choices = choices.filter(active=active)
return choices.filter(value=value).exists()
class SelectionListEntry(models.Model):
"""Class which represents a single entry in a SelectionList.

View File

@ -28,7 +28,7 @@ from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
enable_filter,
OptionalField,
)
from plugin import registry as plugin_registry
from users.serializers import OwnerSerializer, UserSerializer
@ -381,7 +381,16 @@ class ConfigSerializer(serializers.Serializer):
"""Return the configuration data as a dictionary."""
if not isinstance(instance, str):
instance = list(instance.keys())[0]
return {'key': instance, **self.instance.get(instance)}
data = {'key': instance}
for k, v in self.instance.get(instance, {}).items():
if k == 'default_value':
# Skip sensitive default values
continue
data[k] = v
return data
class NotesImageSerializer(InvenTreeModelSerializer):
@ -857,6 +866,7 @@ class ParameterSerializer(
'note',
'updated',
'updated_by',
# Optional fields
'template_detail',
'updated_by_detail',
]
@ -906,17 +916,22 @@ class ParameterSerializer(
allow_null=False,
)
updated_by_detail = enable_filter(
UserSerializer(
source='updated_by', read_only=True, allow_null=True, many=False
),
True,
updated_by_detail = OptionalField(
serializer_class=UserSerializer,
serializer_kwargs={
'source': 'updated_by',
'read_only': True,
'allow_null': True,
'many': False,
},
default_include=True,
prefetch_fields=['updated_by'],
)
template_detail = enable_filter(
ParameterTemplateSerializer(source='template', read_only=True, many=False),
True,
template_detail = OptionalField(
serializer_class=ParameterTemplateSerializer,
serializer_kwargs={'source': 'template', 'read_only': True, 'many': False},
default_include=True,
prefetch_fields=['template', 'template__model_type'],
)

View File

@ -49,7 +49,8 @@ def validate_part_name_format(value):
})
# Attempt to render the template with a dummy Part instance
p = Part(name='test part', description='some test part')
# Use pk=1 to ensure conditional checks like {% if part.pk %} are evaluated
p = Part(pk=1, name='test part', description='some test part')
try:
SandboxedEnvironment().from_string(value).render({'part': p})
@ -234,6 +235,18 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'validator': bool,
'default': False,
},
'INVENTREE_SHOW_SUPERUSER_BANNER': {
'name': _('Show superuser banner'),
'description': _('Show a warning banner in the UI when logged in as superuser'),
'validator': bool,
'default': True,
},
'INVENTREE_SHOW_ADMIN_BANNER': {
'name': _('Show admin banner'),
'description': _('Show a warning banner in the UI when logged in as admin'),
'validator': bool,
'default': False,
},
'INVENTREE_COMPANY_NAME': {
'name': _('Company name'),
'description': _('Internal company name'),

View File

@ -28,6 +28,7 @@ def global_setting_overrides() -> dict:
def get_global_setting(key, backup_value=None, environment_key=None, **kwargs):
"""Return the value of a global setting using the provided key."""
import InvenTree.ready
from common.models import InvenTreeSetting
if environment_key:
@ -38,6 +39,10 @@ def get_global_setting(key, backup_value=None, environment_key=None, **kwargs):
if backup_value is not None:
kwargs['backup_value'] = backup_value
# Prevent database writes if we are in a read-only command
if InvenTree.ready.isReadOnlyCommand():
kwargs['create'] = False
return InvenTreeSetting.get_setting(key, **kwargs)

View File

@ -103,7 +103,9 @@ class EmailTests(InvenTreeAPITestCase):
)
def test_email_api(self):
"""Test that the email api endpoints work."""
self.post(reverse('api-email-test'), {'email': 'test@example.org'})
self.post(
reverse('api-email-test'), {'email': 'test@example.org'}, expected_code=200
)
response = self.get(reverse('api-email-list'), expected_code=200)
self.assertIn('subject', response.data[0])

View File

@ -1304,12 +1304,41 @@ class NotificationTest(InvenTreeAPITestCase):
self.assertEqual(
response.data['description'],
'List view for all notifications of the current user.',
'Notifications for the current user.\n\n- User can only view / delete their own notification objects',
)
# POST action should fail (not allowed)
response = self.post(url, {}, expected_code=405)
def test_api_read(self):
"""Test that NotificationMessage can be marked as read."""
# Create a notification message
NotificationMessage.objects.create(
user=self.user,
category='test',
message='This is a test notification',
target_object=self.user,
)
user2 = get_user_model().objects.get(pk=2)
NotificationMessage.objects.create(
user=user2,
category='test',
message='This is a second test notification',
target_object=user2,
)
url = reverse('api-notifications-list')
self.assertEqual(NotificationMessage.objects.filter(read=True).count(), 0)
self.assertEqual(len(self.get(url, expected_code=200).data), 1)
# Read with readall endpoint
self.post(reverse('api-notifications-readall'), {}, expected_code=200)
self.assertEqual(NotificationMessage.objects.filter(read=True).count(), 1)
self.assertEqual(len(self.get(url, expected_code=200).data), 1)
# filtered by read status should be 0
self.assertEqual(len(self.get(url, {'read': False}, expected_code=200).data), 0)
def test_bulk_delete(self):
"""Tests for bulk deletion of user notifications."""
from error_report.models import Error
@ -1835,7 +1864,7 @@ class CustomUnitAPITest(InvenTreeAPITestCase):
def test_api(self):
"""Test the CustomUnit API."""
response = self.get(reverse('api-all-unit-list'))
response = self.get(reverse('api-custom-unit-all'))
self.assertIn('default_system', response.data)
self.assertIn('available_systems', response.data)
self.assertIn('available_units', response.data)

View File

@ -179,11 +179,13 @@ class ManufacturerPartMixin(SerializerContextMixin):
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related('supplier_parts')
queryset = queryset.prefetch_related('part', 'part__pricing_data')
return queryset
class ManufacturerPartList(
DataExportViewMixin,
ManufacturerPartMixin,
SerializerContextMixin,
OutputOptionsMixin,

View File

@ -17,7 +17,6 @@ from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import (
FilterableCharField,
FilterableSerializerMixin,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
@ -26,8 +25,8 @@ from InvenTree.serializers import (
InvenTreeMoneySerializer,
InvenTreeTagModelSerializer,
NotesFieldMixin,
OptionalField,
RemoteImageMixin,
enable_filter,
)
from .models import (
@ -164,9 +163,10 @@ class CompanySerializer(
return queryset
primary_address = enable_filter(
AddressBriefSerializer(read_only=True, allow_null=True),
False,
primary_address = OptionalField(
serializer_class=AddressBriefSerializer,
serializer_kwargs={'read_only': True, 'many': False, 'allow_null': True},
default_include=False,
filter_name='address_detail',
prefetch_fields=[
Prefetch(
@ -263,27 +263,37 @@ class ManufacturerPartSerializer(
parameters = common.filters.enable_parameters_filter()
part_detail = enable_filter(
part_serializers.PartBriefSerializer(
source='part', many=False, read_only=True, allow_null=True
),
True,
prefetch_fields=['part', 'part__pricing_data', 'part__category'],
part_detail = OptionalField(
serializer_class=part_serializers.PartBriefSerializer,
serializer_kwargs={
'source': 'part',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['part__category'],
)
pretty_name = enable_filter(
FilterableCharField(read_only=True, allow_null=True), filter_name='pretty'
pretty_name = OptionalField(
serializer_class=serializers.CharField,
serializer_kwargs={'read_only': True, 'allow_null': True},
filter_name='pretty',
)
manufacturer = serializers.PrimaryKeyRelatedField(
queryset=Company.objects.filter(is_manufacturer=True)
)
manufacturer_detail = enable_filter(
CompanyBriefSerializer(
source='manufacturer', many=False, read_only=True, allow_null=True
),
True,
manufacturer_detail = OptionalField(
serializer_class=CompanyBriefSerializer,
serializer_kwargs={
'source': 'manufacturer',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['manufacturer'],
)
@ -415,70 +425,86 @@ class SupplierPartSerializer(
pack_quantity_native = serializers.FloatField(read_only=True)
price_breaks = enable_filter(
SupplierPriceBreakBriefSerializer(
source='pricebreaks',
many=True,
read_only=True,
allow_null=True,
label=_('Price Breaks'),
),
False,
price_breaks = OptionalField(
serializer_class=SupplierPriceBreakBriefSerializer,
serializer_kwargs={
'source': 'pricebreaks',
'many': True,
'read_only': True,
'allow_null': True,
'label': _('Price Breaks'),
},
default_include=False,
filter_name='price_breaks',
prefetch_fields=['pricebreaks'],
)
parameters = common.filters.enable_parameters_filter()
part_detail = enable_filter(
part_serializers.PartBriefSerializer(
label=_('Part'), source='part', many=False, read_only=True, allow_null=True
),
False,
part_detail = OptionalField(
serializer_class=part_serializers.PartBriefSerializer,
serializer_kwargs={
'label': _('Part'),
'source': 'part',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['part', 'part__pricing_data'],
)
supplier_detail = enable_filter(
CompanyBriefSerializer(
label=_('Supplier'),
source='supplier',
many=False,
read_only=True,
allow_null=True,
),
False,
supplier_detail = OptionalField(
serializer_class=CompanyBriefSerializer,
serializer_kwargs={
'label': _('Supplier'),
'source': 'supplier',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['supplier'],
)
manufacturer_detail = enable_filter(
CompanyBriefSerializer(
label=_('Manufacturer'),
source='manufacturer_part.manufacturer',
many=False,
read_only=True,
allow_null=True,
),
False,
manufacturer_detail = OptionalField(
serializer_class=CompanyBriefSerializer,
serializer_kwargs={
'label': _('Manufacturer'),
'source': 'manufacturer_part.manufacturer',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['manufacturer_part__manufacturer'],
)
pretty_name = enable_filter(
FilterableCharField(read_only=True, allow_null=True), filter_name='pretty'
pretty_name = OptionalField(
serializer_class=serializers.CharField,
serializer_kwargs={
'read_only': True,
'allow_null': True,
'label': _('Pretty Name'),
},
default_include=False,
filter_name='pretty',
)
supplier = serializers.PrimaryKeyRelatedField(
label=_('Supplier'), queryset=Company.objects.filter(is_supplier=True)
)
manufacturer_part_detail = enable_filter(
ManufacturerPartSerializer(
label=_('Manufacturer Part'),
source='manufacturer_part',
part_detail=False,
read_only=True,
allow_null=True,
),
False,
manufacturer_part_detail = OptionalField(
serializer_class=ManufacturerPartSerializer,
serializer_kwargs={
'label': _('Manufacturer Part'),
'source': 'manufacturer_part',
'part_detail': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['manufacturer_part'],
)
@ -568,18 +594,27 @@ class SupplierPriceBreakSerializer(
return queryset
supplier_detail = enable_filter(
CompanyBriefSerializer(
source='part.supplier', many=False, read_only=True, allow_null=True
),
False,
supplier_detail = OptionalField(
serializer_class=CompanyBriefSerializer,
serializer_kwargs={
'source': 'part.supplier',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['part__supplier'],
)
part_detail = enable_filter(
SupplierPartSerializer(
source='part', brief=True, many=False, read_only=True, allow_null=True
),
False,
part_detail = OptionalField(
serializer_class=SupplierPartSerializer,
serializer_kwargs={
'source': 'part',
'brief': True,
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['part', 'part__part', 'part__part__pricing_data'],
)

View File

@ -507,6 +507,8 @@ class ManufacturerTest(InvenTreeAPITestCase):
"""Tests for the ManufacturerPart detail endpoint."""
mp = ManufacturerPart.objects.first()
self.assertIsNotNone(mp)
url = reverse('api-manufacturer-part-detail', kwargs={'pk': mp.pk})
response = self.get(url)

View File

@ -394,7 +394,14 @@ class DataImportSession(models.Model):
if serializer_class := self.serializer_class:
serializer = serializer_class(data={}, importing=True)
fields.update(metadata.get_serializer_info(serializer))
serializer_fields = metadata.get_serializer_info(serializer)
for field_name, field in serializer_fields.items():
# Skip read-only fields
if field.get('read_only', False):
continue
fields[field_name] = field
# Cache the available fields against this instance
self._available_fields = fields

View File

@ -19,14 +19,14 @@ class DataImportSerializerRegister:
def register(self, serializer) -> None:
"""Register a new serializer with the importer registry."""
if not issubclass(serializer, DataImportSerializerMixin):
logger.debug('Invalid serializer class: %s', type(serializer))
logger.debug('Invalid serializer class: %s', serializer.__name__)
return
if not issubclass(serializer, Serializer):
logger.debug('Invalid serializer class: %s', type(serializer))
logger.debug('Invalid serializer class: %s', serializer.__name__)
return
logger.debug('Registering serializer class for import: %s', type(serializer))
logger.debug('Registering serializer class for import: %s', serializer.__name__)
if serializer not in self.supported_serializers:
self.supported_serializers.append(serializer)

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