Merge branch 'master' into notify-context

This commit is contained in:
Matthias Mair 2026-04-11 00:11:57 +02:00 committed by GitHub
commit 0468d8b3cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
350 changed files with 150846 additions and 137144 deletions

View File

@ -13,5 +13,5 @@ runs:
invoke export-records -f data.json
python3 ./src/backend/InvenTree/manage.py flush --noinput
invoke migrate
invoke import-records -c -f data.json
invoke import-records -c -f data.json
invoke import-records -c -f data.json --strict
invoke import-records -c -f data.json --strict

View File

@ -15,6 +15,10 @@ inputs:
required: false
description: 'Install the InvenTree requirements?'
default: 'false'
static:
required: false
description: 'Should the static files be built?'
default: 'false'
dev-install:
required: false
description: 'Install the InvenTree development requirements?'
@ -103,3 +107,7 @@ runs:
if: ${{ inputs.update == 'true' }}
shell: bash
run: invoke update --skip-backup --skip-static
- name: Collect static files
if: ${{ inputs.static == 'true' }}
shell: bash
run: invoke static --skip-plugins

101
.github/scripts/check_exported_data.py vendored Normal file
View File

@ -0,0 +1,101 @@
"""Script to check a data file exported using the 'export-records' command.
This script is intended to be used as part of the CI workflow,
in conjunction with the "workflows/import_export.yaml" workflow.
In reads the exported data file, to ensure that:
- The file can be read and parsed as JSON
- The file contains the expected metadata
- The file contains the expected plugin configuration
- The file contains the expected plugin database records
"""
PLUGIN_KEY = 'dummy_app_plugin'
PLUGIN_SLUG = 'dummy-app-plugin'
import argparse
import json
import os
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Check exported data file')
parser.add_argument('datafile', help='Path to the exported data file (JSON)')
args = parser.parse_args()
if not os.path.isfile(args.datafile):
print(f'Error: File not found: {args.datafile}')
exit(1)
with open(args.datafile, encoding='utf-8') as f:
try:
data = json.load(f)
print(f'Successfully loaded data from {args.datafile}')
print(f'Number of records: {len(data)}')
except json.JSONDecodeError as e:
print(f'Error: Failed to parse JSON file: {e}')
exit(1)
found_metadata = False
found_installed_apps = False
found_plugin_config = False
plugin_data_records = {}
# Inspect the data and check that it has the expected structure and content.
for entry in data:
# Check metadata entry for expected values
if entry.get('metadata', False):
print('Found metadata entry')
found_metadata = True
expected_apps = ['InvenTree', 'allauth', 'dbbackup', PLUGIN_KEY]
apps = entry.get('installed_apps', [])
for app in expected_apps:
if app not in apps:
print(f'- Expected app "{app}" not found in installed apps list')
exit(1)
found_installed_apps = True
elif entry.get('model', None) == 'plugin.pluginconfig':
key = entry['fields']['key']
if key == PLUGIN_SLUG:
print(f'Found plugin configuration for plugin "{PLUGIN_KEY}"')
found_plugin_config = True
elif entry.get('model', None) == f'{PLUGIN_KEY}.examplemodel':
key = entry['fields']['key']
value = entry['fields']['value']
plugin_data_records[key] = value
if not found_metadata:
print('Error: No metadata entry found in exported data')
exit(1)
if not found_installed_apps:
print(
f'Error: Plugin "{PLUGIN_KEY}" not found in installed apps list in metadata'
)
exit(1)
if not found_plugin_config:
print(f'Error: No plugin configuration found for plugin "{PLUGIN_KEY}"')
exit(1)
# Check the extracted plugin records
expected_keys = ['alpha', 'beta', 'gamma', 'delta']
for key in expected_keys:
if key not in plugin_data_records:
print(
f'Error: Expected plugin record with key "{key}" not found in exported data'
)
exit(1)
print('All checks passed successfully!')

View File

@ -42,7 +42,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
id: filter
with:
filters: |
@ -168,13 +168,13 @@ jobs:
echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV
- name: Set up QEMU
if: github.event_name != 'pull_request'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # pin@v3.7.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # pin@v4.0.0
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # pin@v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # pin@v4.0.0
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # pin@v4.0.0
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # pin@v4.1.1
- name: Check if Dockerhub login is required
id: docker_login
run: |
@ -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@c94ce9fb468520275223c153574b00df6fe4bcc9 # pin@v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # pin@v4.0.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@c94ce9fb468520275223c153574b00df6fe4bcc9 # pin@v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # pin@v4.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -201,7 +201,7 @@ jobs:
- name: Extract Docker metadata
if: github.event_name != 'pull_request'
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # pin@v5.10.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # pin@v6.0.0
with:
images: |
inventree/inventree

120
.github/workflows/import_export.yaml vendored Normal file
View File

@ -0,0 +1,120 @@
# Ensure that data import / export functionality works as expected.
# - Create a dataset in a Postgres database (including plugin data)
# - Export the dataset to an agnostic format (JSON)
# - Import the dataset into a Sqlite database
name: Import / Export
on:
push:
branches-ignore: ["l10*"]
pull_request:
branches-ignore: ["l10*"]
permissions:
contents: read
env:
python_version: 3.11
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DEBUG: false
INVENTREE_LOG_LEVEL: WARNING
INVENTREE_MEDIA_ROOT: /home/runner/work/InvenTree/test_inventree_media
INVENTREE_STATIC_ROOT: /home/runner/work/InvenTree/test_inventree_static
INVENTREE_BACKUP_DIR: /home/runner/work/InvenTree/test_inventree_backup
INVENTREE_SITE_URL: http://localhost:8000
INVENTREE_PLUGINS_ENABLED: true
INVENTREE_AUTO_UPDATE: true
INVENTREE_PLUGINS_MANDATORY: "dummy-app-plugin"
INVENTREE_GLOBAL_SETTINGS: '{"ENABLE_PLUGINS_APP": true}'
DATA_FILE: /home/runner/work/InvenTree/test_inventree_data.json
INVENTREE_DB_ENGINE: postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
jobs:
paths-filter:
name: filter
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
id: filter
with:
filters: |
server:
- .github/workflows/import_export.yaml
- .github/scripts/check_exported_data.py
- 'src/backend/**'
- 'tasks.py'
test:
runs-on: ubuntu-latest
needs: paths-filter
if: needs.paths-filter.outputs.server == 'true' || contains(github.event.pull_request.labels.*.name, 'full-run')
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg
update: true
static: false
- name: Setup Postgres Database
run: |
invoke migrate
invoke dev.setup-test -i
- name: Create Plugin Data
run: |
pip install -U inventree-dummy-app-plugin
invoke migrate
cd src/backend/InvenTree && python manage.py create_dummy_data
- name: Export Postgres Dataset
run: |
invoke export-records -o -f ${{ env.DATA_FILE }}
python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }}
invoke dev.delete-data --force
- name: Update Environment Variables for Sqlite
run: |
echo "Updating environment variables for Sqlite"
echo "INVENTREE_DB_ENGINE=sqlite" >> $GITHUB_ENV
echo "INVENTREE_DB_NAME=/home/runner/work/InvenTree/test_inventree_db.sqlite3" >> $GITHUB_ENV
- name: Setup Sqlite Database
run: |
invoke migrate
test -f /home/runner/work/InvenTree/test_inventree_db.sqlite3 || (echo "Sqlite database not created" && exit 1)
- name: Import Sqlite Dataset
run: |
invoke import-records -c -f ${{ env.DATA_FILE }} --strict
cd src/backend/InvenTree && python manage.py check_dummy_data
- name: Export Sqlite Dataset
run: |
invoke export-records -o -f ${{ env.DATA_FILE }}
python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }}

View File

@ -47,7 +47,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
id: filter
with:
filters: |
@ -222,7 +222,7 @@ jobs:
echo "Downloaded api.yaml"
- name: Running OpenAPI Spec diff action
id: breaking_changes
uses: oasdiff/oasdiff-action/diff@1c611ffb1253a72924624aa4fb662e302b3565d3 # pin@main
uses: oasdiff/oasdiff-action/diff@1f38ea5ea0b4a2e4e49901c3bcdf4386a05e9ea1 # pin@main
with:
base: "api.yaml"
revision: "src/backend/InvenTree/schema.yml"
@ -284,7 +284,7 @@ jobs:
- name: Create artifact directory
run: mkdir -p artifact
- name: Download schema artifact
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # pin@v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pin@v8.0.1
with:
path: artifact
merge-multiple: true
@ -341,6 +341,7 @@ jobs:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
static: true
npm: true
- name: Download Python Code For `${WRAPPER_NAME}`
run: git clone --depth 1 https://github.com/inventree/${WRAPPER_NAME} ./${WRAPPER_NAME}
@ -362,7 +363,7 @@ jobs:
pip install .
if: needs.paths-filter.outputs.submit-performance == 'true'
- name: Performance Reporting
uses: CodSpeedHQ/action@dbda7111f8ac363564b0c51b992d4ce76bb89f2f # pin@v4
uses: CodSpeedHQ/action@1c8ae4843586d3ba879736b7f6b7b0c990757fab # 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:
@ -398,6 +399,7 @@ jobs:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
static: true
- name: Data Export Test
uses: ./.github/actions/migration
- name: Test Translations
@ -413,7 +415,7 @@ jobs:
path: .coverage
retention-days: 14
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # pin@v5.5.2
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v6.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -452,7 +454,7 @@ jobs:
env:
node_version: '>=20.19.0'
- name: Performance Reporting
uses: CodSpeedHQ/action@dbda7111f8ac363564b0c51b992d4ce76bb89f2f # pin@v4
uses: CodSpeedHQ/action@1c8ae4843586d3ba879736b7f6b7b0c990757fab # pin@v4
with:
mode: walltime
run: inv dev.test --pytest
@ -500,6 +502,7 @@ jobs:
pip-dependency: psycopg django-redis>=5.0.0
dev-install: true
update: true
static: true
- name: Run Tests
run: invoke dev.test --check --translations
- name: Data Export Test
@ -548,6 +551,7 @@ jobs:
pip-dependency: mysqlclient
dev-install: true
update: true
static: true
- name: Run Tests
run: invoke dev.test --check --translations
- name: Data Export Test
@ -593,7 +597,7 @@ jobs:
- name: Run Tests
run: invoke dev.test --check --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # pin@v5.5.2
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v6.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -702,7 +706,7 @@ jobs:
npm: true
install: true
update: true
apt-dependency: postgresql-client libpq-dev
apt-dependency: gettext postgresql-client libpq-dev
pip-dependency: psycopg2
- name: Set up test data
run: |
@ -730,7 +734,7 @@ jobs:
- name: Report coverage
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # pin@v5.5.2
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v6.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
@ -759,7 +763,7 @@ jobs:
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && yarn run compile && yarn run build
run: cd src/frontend && yarn run compile && yarn run lib && yarn run build
- name: Write version file - SHA
run: cd src/backend/InvenTree/web/static/web/.vite && echo "$GITHUB_SHA" > sha.txt
- name: Zip frontend
@ -785,13 +789,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # pin@v2
- uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # pin@v2
- name: Run zizmor
run: uvx zizmor --format sarif . > results.sarif
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # pin@v3
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # pin@v3
with:
sarif_file: results.sarif
category: zizmor

View File

@ -55,7 +55,7 @@ jobs:
- name: Build frontend
run: cd src/frontend && npm run compile && npm run build
- name: Create SBOM for frontend
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # pin@v0
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # pin@v0
with:
artifact-name: frontend-build.spdx
path: src/frontend
@ -78,7 +78,7 @@ jobs:
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
- name: Upload frontend
uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # pin@2.11.4
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # pin@2.11.5
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: src/backend/InvenTree/web/static/frontend-build.zip
@ -91,7 +91,7 @@ jobs:
name: frontend-build
path: src/backend/InvenTree/web/static/frontend-build.zip
- name: Upload Attestation
uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # pin@2.11.4
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # pin@2.11.5
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
asset_name: frontend-build.intoto.jsonl
@ -134,7 +134,7 @@ jobs:
cd docs/site
zip -r docs-html.zip *
- name: Publish documentation
uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # pin@2.11.4
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # pin@2.11.5
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: docs/site/docs-html.zip
@ -163,7 +163,7 @@ jobs:
persist-credentials: false
- name: Get frontend artifact
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # pin@v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pin@v8.0.1
with:
name: frontend-build
- name: Setup
@ -244,7 +244,7 @@ jobs:
channel: ${{ env.pkg_channel }}
file: ${{ steps.package.outputs.package_path }}
- name: Publish to artifact
uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # pin@2.11.4
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # pin@2.11.5
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.package.outputs.package_path }}

View File

@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
sarif_file: results.sarif

View File

@ -56,7 +56,7 @@ jobs:
echo "Resetting to HEAD~"
git reset HEAD~ || true
- name: crowdin action
uses: crowdin/github-action@8818ff65bfc4322384f983ea37e3926948c11745 # pin@v2
uses: crowdin/github-action@7ca9c452bfe9197d3bb7fa83a4d7e2b0c9ae835d # pin@v2
with:
upload_sources: true
upload_translations: false

View File

@ -20,6 +20,7 @@ before:
- contrib/packager.io/before.sh
dependencies:
- curl
- poppler-utils
- "python3.11 | python3.12 | python3.13 | python3.14"
- "python3.11-venv | python3.12-venv | python3.13-venv | python3.14-venv"
- "python3.11-dev | python3.12-dev | python3.13-dev | python3.14-dev"

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

@ -10,20 +10,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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.
- [#11222](https://github.com/inventree/InvenTree/pull/11222) adds support for data import using natural keys, allowing for easier association of related objects without needing to know their internal database IDs.
- [#11383](https://github.com/inventree/InvenTree/pull/11383) adds "exists_for_model_id", "exists_for_related_model", and "exists_for_related_model_id" filters to the ParameterTemplate API endpoint. These filters allow users to check for the existence of parameters associated with specific models or related models, improving the flexibility and usability of the API.
- [#10887](https://github.com/inventree/InvenTree/pull/10887) adds the ability to auto-allocate tracked items against specific build outputs. Currently, this will only allocate items where the serial number of the tracked item matches the serial number of the build output, but in future this may be extended to allow for more flexible allocation rules.
- [#11372](https://github.com/inventree/InvenTree/pull/11372) adds backup metadata setter and restore metadata validator functions to ensure common footguns are harder to trigger when using the backup and restore functionality.
- [#11374](https://github.com/inventree/InvenTree/pull/11374) adds `updated_at` field on purchase, sales and return orders.
- [#11074](https://github.com/inventree/InvenTree/pull/11074) adds "Keep form open" option on create form which leaves dialog with form opened after form submitting.
### Changed
- [#11648](https://github.com/inventree/InvenTree/pull/11648) improves the import/export process, allowing data records defined by plugins to be loaded when importing a database from a file.
- [#11630](https://github.com/inventree/InvenTree/pull/11630) enhances the `import_records` and `export_records` system commands, by adding a metadata entry to the exported data file to allow for compatibility checks during data import.
### Removed
- [#11581](https://github.com/inventree/InvenTree/pull/11581) removes the ability to specify arbitrary filters when performing bulk operations via the API. This functionality represented a significant security risk, and was not required for any existing use cases. Bulk operations now only work with a provided list of primary keys.
## 1.2.0 - 2026-02-12
### Breaking Changes

View File

@ -9,8 +9,8 @@
# - Runs InvenTree web server under django development server
# - Monitors source files for any changes, and live-reloads server
# Base image last bumped 2026-02-23
FROM python:3.14-slim-trixie@sha256:486b8092bfb12997e10d4920897213a06563449c951c5506c2a2cfaf591c599f AS inventree_base
# Base image last bumped 2026-03-20
FROM python:3.14-slim-trixie@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca AS inventree_base
# Build arguments for this image
ARG commit_tag=""

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,9 +17,9 @@ django-auth-ldap==5.3.0 \
--hash=sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19 \
--hash=sha256:aa880415983149b072f876d976ef8ec755a438090e176817998263a6ed9e1038
# via -r contrib/container/requirements.in
gunicorn==25.1.0 \
--hash=sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616 \
--hash=sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b
gunicorn==25.3.0 \
--hash=sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660 \
--hash=sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in
@ -44,15 +44,14 @@ mariadb==1.1.14 \
--hash=sha256:98d552a8bb599eceaa88f65002ad00bd88aeed160592c273a7e5c1d79ab733dd \
--hash=sha256:e6d702a53eccf20922e47f2f45cfb5c7a0c2c6c0a46e4ee2d8a80d0ff4a52f34
# via -r contrib/container/requirements.in
mysqlclient==2.2.7 \
--hash=sha256:199dab53a224357dd0cb4d78ca0e54018f9cee9bf9ec68d72db50e0a23569076 \
--hash=sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5 \
--hash=sha256:24ae22b59416d5fcce7e99c9d37548350b4565baac82f95e149cac6ce4163845 \
--hash=sha256:2e3c11f7625029d7276ca506f8960a7fd3c5a0a0122c9e7404e6a8fe961b3d22 \
--hash=sha256:4b4c0200890837fc64014cc938ef2273252ab544c1b12a6c1d674c23943f3f2e \
--hash=sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585 \
--hash=sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069 \
--hash=sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687
mysqlclient==2.2.8 \
--hash=sha256:000c7ec3d11e7c411db832e4cfcd7f05db47464326381f5d5ae991b4bb572f93 \
--hash=sha256:260cce0e81446c83bf0a389e0fae38d68547d9f8fc0833bc733014e10ce28a99 \
--hash=sha256:60c9ed339dc09e3d5380cc2a9f42e86754fee25a661ced77a02df77990664c92 \
--hash=sha256:86db31bba7b3480fec2751350e9790e24f016f89af33a87bab7e79f7196474e8 \
--hash=sha256:8ed20c5615a915da451bb308c7d0306648a4fd9a2809ba95c992690006306199 \
--hash=sha256:9bed7c8d3b629bdc09e17fb628d5b3b0a5fd1f12b09432b464b9126c727bedc0 \
--hash=sha256:a81f5e12f8d05439709cb02fba97f9f76d1a6c528164f2260d8798fec969e300
# via -r contrib/container/requirements.in
packaging==26.0 \
--hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \
@ -62,74 +61,74 @@ packaging==26.0 \
# gunicorn
# mariadb
# wheel
psycopg[binary, pool]==3.3.2 \
--hash=sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b \
--hash=sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7
psycopg[binary, pool]==3.3.3 \
--hash=sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9 \
--hash=sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698
# via -r contrib/container/requirements.in
psycopg-binary==3.3.2 \
--hash=sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9 \
--hash=sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1 \
--hash=sha256:0611f4822674f3269e507a307236efb62ae5a828fcfc923ac85fe22ca19fd7c8 \
--hash=sha256:0768c5f32934bb52a5df098317eca9bdcf411de627c5dca2ee57662b64b54b41 \
--hash=sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b \
--hash=sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2 \
--hash=sha256:09b3014013f05cd89828640d3a1db5f829cc24ad8fa81b6e42b2c04685a0c9d4 \
--hash=sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7 \
--hash=sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9 \
--hash=sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678 \
--hash=sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0 \
--hash=sha256:1ea41c0229f3f5a3844ad0857a83a9f869aa7b840448fa0c200e6bcf85d33d19 \
--hash=sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed \
--hash=sha256:3789d452a9d17a841c7f4f97bbcba51a21f957ea35641a4c98507520e6b6a068 \
--hash=sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688 \
--hash=sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c \
--hash=sha256:44e89938d36acc4495735af70a886d206a5bfdc80258f95b69b52f68b2968d9e \
--hash=sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e \
--hash=sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e \
--hash=sha256:522b79c7db547767ca923e441c19b97a2157f2f494272a119c854bba4804e186 \
--hash=sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860 \
--hash=sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4 \
--hash=sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924 \
--hash=sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf \
--hash=sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d \
--hash=sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0 \
--hash=sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a \
--hash=sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7 \
--hash=sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca \
--hash=sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121 \
--hash=sha256:8ea05b499278790a8fa0ff9854ab0de2542aca02d661ddff94e830df971ff640 \
--hash=sha256:90ed9da805e52985b0202aed4f352842c907c6b4fc6c7c109c6e646c32e2f43b \
--hash=sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa \
--hash=sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4 \
--hash=sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390 \
--hash=sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634 \
--hash=sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f \
--hash=sha256:c3a9ccdfee4ae59cf9bf1822777e763bc097ed208f4901e21537fca1070e1391 \
--hash=sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c \
--hash=sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12 \
--hash=sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443 \
--hash=sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d \
--hash=sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d \
--hash=sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578 \
--hash=sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14 \
--hash=sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed \
--hash=sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d \
--hash=sha256:de9173f8cc0efd88ac2a89b3b6c287a9a0011cdc2f53b2a12c28d6fd55f9f81c \
--hash=sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc \
--hash=sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da \
--hash=sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b \
--hash=sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30 \
--hash=sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3 \
--hash=sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7 \
--hash=sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b
psycopg-binary==3.3.3 \
--hash=sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d \
--hash=sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12 \
--hash=sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e \
--hash=sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430 \
--hash=sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc \
--hash=sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383 \
--hash=sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b \
--hash=sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40 \
--hash=sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84 \
--hash=sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd \
--hash=sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351 \
--hash=sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8 \
--hash=sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d \
--hash=sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d \
--hash=sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51 \
--hash=sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2 \
--hash=sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1 \
--hash=sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d \
--hash=sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6 \
--hash=sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc \
--hash=sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e \
--hash=sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14 \
--hash=sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead \
--hash=sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d \
--hash=sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e \
--hash=sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b \
--hash=sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1 \
--hash=sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1 \
--hash=sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8 \
--hash=sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83 \
--hash=sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13 \
--hash=sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b \
--hash=sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2 \
--hash=sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181 \
--hash=sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6 \
--hash=sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830 \
--hash=sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023 \
--hash=sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048 \
--hash=sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1 \
--hash=sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0 \
--hash=sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c \
--hash=sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2 \
--hash=sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d \
--hash=sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508 \
--hash=sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482 \
--hash=sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e \
--hash=sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628 \
--hash=sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6 \
--hash=sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925 \
--hash=sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856 \
--hash=sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f \
--hash=sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df \
--hash=sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7 \
--hash=sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25 \
--hash=sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0
# via psycopg
psycopg-pool==3.3.0 \
--hash=sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063 \
--hash=sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5
# via psycopg
pyasn1==0.6.2 \
--hash=sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf \
--hash=sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b
pyasn1==0.6.3 \
--hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \
--hash=sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde
# via
# pyasn1-modules
# python-ldap
@ -219,9 +218,9 @@ pyyaml==6.0.3 \
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in
setuptools==82.0.0 \
--hash=sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb \
--hash=sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0
setuptools==82.0.1 \
--hash=sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9 \
--hash=sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in
@ -237,26 +236,26 @@ typing-extensions==4.15.0 \
# via
# -c src/backend/requirements.txt
# psycopg-pool
uv==0.9.22 \
--hash=sha256:012bdc5285a9cdb091ac514b7eb8a707e3b649af5355fe4afb4920bfe1958c00 \
--hash=sha256:0cdc653fb601aa7f273242823fa93024f5fd319c66cdf22f36d784858493564c \
--hash=sha256:0d8f007616cac5962620252b56a1d8224e9b2de566e78558efe04cc18526d507 \
--hash=sha256:1f45e1e0f26dd47fa01eb421c54cfd39de10fd52ac0a9d7ae45b92fce7d92b0b \
--hash=sha256:1f979c9d313b4616d9865859ef520bea5df0d4f15c57214589f5676fafa440c1 \
--hash=sha256:2a4155cf7d0231d0adae94257ee10d70c57c2f592207536ddd55d924590a8c15 \
--hash=sha256:3422b093b8e6e8de31261133b420c34dbef81f3fd1d82f787ac771b00b54adf8 \
--hash=sha256:369b55341c6236f42d8fc335876308e5c57c921850975b3019cc9f7ebbe31567 \
--hash=sha256:3b2bcce464186f8fafa3bf2aa5d82db4e3229366345399cc3f5bcafd616b8fe0 \
--hash=sha256:41c73a4938818ede30e601cd0be87953e5c6a83dc4762e04e626f2eb9b240ebe \
--hash=sha256:59c4f6b3659a68c26c50865432a7134386f607432160aad51e2247f862902697 \
--hash=sha256:77ec4c101d41d7738226466191a7d62f9fa4de06ea580e0801da2f5cd5fa08aa \
--hash=sha256:8f73043ade8ff6335e19fe1f4e7425d5e28aec9cafd72d13d5b40bb1cbb85690 \
--hash=sha256:9c238525272506845fe07c0b9088c5e33fcd738e1f49ef49dc3c8112096d2e3a \
--hash=sha256:b1985559b38663642658069e8d09fa6c30ed1c67654b7e5765240d9e4e9cdd57 \
--hash=sha256:b78f2605d65c4925631d891dec99b677b05f50c774dedc6ef8968039a5bcfdb0 \
--hash=sha256:b807bafe6b65fc1fe9c65ffd0d4228db894872de96e7200c44943f24beb68931 \
--hash=sha256:d9d4be990bb92a68781f7c98d2321b528667b61d565c02ba978488c0210aa768 \
--hash=sha256:e4b61a9c8b8dcbf64e642d2052342d36a46886b8bc3ccc407282962b970101af
uv==0.10.12 \
--hash=sha256:006812a086fce03d230fc987299f7295c7a73d17a1f1c17de1d1f327826f8481 \
--hash=sha256:101481a1f48db6becf219914a591a588c0b3bfd05bef90768a5d04972bd6455e \
--hash=sha256:2ace05115bd9ee1b30d341728257fe051817c4c0a652c085c90d4bd4fb0bc8f2 \
--hash=sha256:2bb5893d79179727253e4a283871a693d7773c662a534fb897aa65496aa35765 \
--hash=sha256:2c21e1b36c384f75dd3fd4a818b04871158ce115efff0bb4fdcd18ba2df7bd48 \
--hash=sha256:2c5dfc7560453186e911c8c2e4ce95cd1c91e1c5926c3b34c5a825a307217be9 \
--hash=sha256:384b7f36a1ae50efe5f50fe299f276a83bf7acc8b7147517f34e27103270f016 \
--hash=sha256:551f799d53e397843b6cde7e3c61de716fb487da512a21a954b7d0cbc06967e0 \
--hash=sha256:6727e3a0208059cd4d621684e580d5e254322dacbd806e0d218360abd0d48a68 \
--hash=sha256:7099bdefffbe2df81accad52579657b8f9f870170caa779049c9fd82d645c9b3 \
--hash=sha256:76ebe11572409dfbe20ec25a823f9bc8781400ece5356aa33ec44903af7ec316 \
--hash=sha256:8dc352c93a47a4760cf824c31c55ce26511af780481e8f67c796d2779acaa928 \
--hash=sha256:a5afe619e8a861fe4d49df8e10d2c6963de0dac6b79350c4832bf3366c8496cf \
--hash=sha256:b9ca1d264059cb016c853ebbc4f21c72d983e0f347c927ca29e283aec2f596cf \
--hash=sha256:bd84379292e3c1a1bf0a05847c7c72b66bb581dccf8da1ef94cc82bf517efa7c \
--hash=sha256:be85acae8f31c68311505cd96202bad43165cbd7be110c59222f918677e93248 \
--hash=sha256:cca36540d637c80d11d8a44a998a068355f0c78b75ec6b0f152ecbf89dfdd67b \
--hash=sha256:e0f0ef58f0ba6fbfaf5f91b67aad6852252c49b8f78015a2a5800cf74c7538d5 \
--hash=sha256:fa722691c7ae5c023778ad0b040ab8619367bcfe44fd0d9e05a58751af86cdf8
# via -r contrib/container/requirements.in
wheel==0.46.3 \
--hash=sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d \

View File

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

View File

@ -6,120 +6,136 @@ certifi==2026.2.25 \
# via
# -c src/backend/requirements.txt
# requests
charset-normalizer==3.4.4 \
--hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \
--hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \
--hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \
--hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \
--hash=sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc \
--hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \
--hash=sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63 \
--hash=sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d \
--hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \
--hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \
--hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \
--hash=sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505 \
--hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \
--hash=sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af \
--hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \
--hash=sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318 \
--hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \
--hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \
--hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \
--hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \
--hash=sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576 \
--hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \
--hash=sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1 \
--hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \
--hash=sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1 \
--hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \
--hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \
--hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \
--hash=sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88 \
--hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \
--hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \
--hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \
--hash=sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a \
--hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \
--hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \
--hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \
--hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \
--hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \
--hash=sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7 \
--hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \
--hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \
--hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \
--hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \
--hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \
--hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \
--hash=sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2 \
--hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \
--hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \
--hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \
--hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \
--hash=sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf \
--hash=sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6 \
--hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \
--hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \
--hash=sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa \
--hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \
--hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \
--hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \
--hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \
--hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \
--hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \
--hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \
--hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \
--hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \
--hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \
--hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \
--hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \
--hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \
--hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \
--hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \
--hash=sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3 \
--hash=sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9 \
--hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \
--hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \
--hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \
--hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \
--hash=sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50 \
--hash=sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf \
--hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \
--hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \
--hash=sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac \
--hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \
--hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \
--hash=sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c \
--hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \
--hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \
--hash=sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e \
--hash=sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4 \
--hash=sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84 \
--hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \
--hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \
--hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \
--hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \
--hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \
--hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \
--hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \
--hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \
--hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \
--hash=sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074 \
--hash=sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3 \
--hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \
--hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \
--hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \
--hash=sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d \
--hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \
--hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \
--hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \
--hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \
--hash=sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966 \
--hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \
--hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \
--hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \
--hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608
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
# via
# -c src/backend/requirements.txt
# requests
@ -133,9 +149,9 @@ jc==1.25.6 \
--hash=sha256:27f58befc7ae0a4c63322926c5f1ec892e3eac4a065eff3b07cfe420a6924a07 \
--hash=sha256:7367b59e6e0da8babeede1e5b0da083f3c5aa6b6e585b4aed28dd7c4b2d76162
# via -r contrib/dev_reqs/requirements.in
pygments==2.19.2 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via jc
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
@ -214,9 +230,9 @@ pyyaml==6.0.3 \
# via
# -c src/backend/requirements.txt
# -r contrib/dev_reqs/requirements.in
requests==2.32.5 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
requests==2.33.0 \
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via
# -c src/backend/requirements.txt
# -r contrib/dev_reqs/requirements.in
@ -230,7 +246,7 @@ urllib3==2.6.3 \
# via
# -c src/backend/requirements.txt
# requests
xmltodict==1.0.2 \
--hash=sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649 \
--hash=sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d
xmltodict==1.0.4 \
--hash=sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61 \
--hash=sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a
# via jc

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 |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 50 KiB

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

@ -16,7 +16,7 @@ Deploying InvenTree to production requires to knowledge of the security assumpti
2. All users are trusted - therefore user uploaded files can be assumed to be safe. There are basic checks in place to ensure that the files are not using common attack vectors but those are not exhaustive.
3. Superuser permissions are only given to trusted users and not used for daily operations. A superuser account can manipulate or extract all files on the server that the InvenTree server process have access to.
3. Superuser or staff permissions are only given to trusted users and not used for daily operations. A superuser account can manipulate or extract all files on the server that the InvenTree server process have access to. See [dangerous user flags](../settings/permissions.md#dangerous-user-flags) for more details on user permissions and flags.
4. All templates and plugins are trusted.

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

@ -224,6 +224,8 @@ Example: Creating a new part via the "Add Part" form:
{{ image("concepts/ui_form_add_part.png", "Add Part Button") }}
On several forms is displayed option "Keep form open" in bottom part of the form on left side of Submit button (option is visible on the screenshot above). When this switch is turned on, form window is not closed after submit and filled form data is not reset. This is useful for creating more entries at one time with similar properties (e.g. only different number in name).
### Data Editing
Example: Editing an existing purchase order via the "Edit Purchase Order" form:
@ -280,11 +282,12 @@ Alternatively, the spotlight search can be opened using the keyboard shortcut `C
Users may opt to disable the spotlight search functionality if they do not find it useful or prefer not to use it. To disable the spotlight search, navigate to your [user settings](../settings/user.md) and locate the option to disable the spotlight feature. Once disabled, the spotlight search will no longer be accessible from the main menu or via keyboard shortcuts.
## Barcode Scanning
## Copy Button
## Notifications
Many fields within the InvenTree user interface include a "copy" button, which allows users to quickly copy the value of that field to their clipboard. This is particularly useful for fields that contain important identifiers, such as part numbers, stock item codes, or other relevant data that may need to be easily copied and pasted elsewhere.
## Customization
!!! important "Secure Context"
The "copy" button functionality relies on the browser's clipboard API, which may not be available in all contexts (e.g. if the user is accessing the InvenTree interface via a non-https connection, or through an embedded iframe or a non-standard browser). In such cases, the "copy" button may not function as intended.
## User Permissions

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

@ -37,6 +37,12 @@ In the example below, see that the *Wood Screw* line item is marked as consumabl
Further, in the [Build Order](./build.md) stock allocation table, we see that this line item cannot be allocated, as it is *consumable*.
### Optional BOM Line Items
If a BOM line item is marked as *optional*, this means that the part and quantity information is tracked in the BOM, but this line item is not required to be allocated to a [Build Order](./build.md). This may be useful for certain items which are not strictly required for the build process to be completed.
When completing a Build Order, the user can choose whether to include optional items in the build process or not. If optional items are included, they will be allocated to the Build Order as normal. If optional items are excluded, they will not be allocated to the Build Order, and the build process can be completed without them.
### Substitute BOM Line Items
Where alternative parts can be used when building an assembly, these parts are assigned as *Substitute* parts in the Bill of Materials. A particular line item may have multiple substitute parts assigned to it. When allocating stock to a [Build Order](./build.md), stock items associated with any of the substitute parts may be allocated against the particular line item.

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

@ -27,24 +27,22 @@ When creating a new revision of a part, there are some restrictions which must b
* **Circular References**: A part cannot be a revision of itself. This would create a circular reference which is not allowed.
* **Unique Revisions**: A part cannot have two revisions with the same revision number. Each revision (of a given part) must have a unique revision code.
* **Revisions of Revisions**: A single part can have multiple revisions, but a revision cannot have its own revision. This restriction is in place to prevent overly complex part relationships.
* **Template Revisions**: A part which is a [template part](./template.md) cannot have revisions. This is because the template part is used to create variants, and allowing revisions of templates would create disallowed relationship states in the database. However, variant parts are allowed to have revisions.
* **Template References**: A part which is a revision of a variant part must point to the same template as the original part. This is to ensure that the revision is correctly linked to the original part.
## Revision Settings
The following options are available to control the behavior of part revisions.
The following [global settings](../settings/global.md) are available to control the behavior of part revisions:
Note that these options can be changed in the InvenTree settings:
| Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- |
{{ globalsetting("PART_ENABLE_REVISION") }}
{{ globalsetting("PART_REVISION_ASSEMBLY_ONLY") }}
{{ image("part/part_revision_settings.png", "Part revision settings") }}
* **Enable Revisions**: If this setting is enabled, parts can have revisions. If this setting is disabled, parts cannot have revisions.
* **Assembly Revisions Only**: If this setting is enabled, only assembly parts can have revisions. This is useful if you only want to track revisions of assemblies, and not individual parts.
## Create a Revision
To create a new revision for a given part, navigate to the part detail page, and click on the "Revisions" tab.
To create a new revision for a given part, navigate to the part detail page, and click on the part actions menu (three vertical dots on the top right of the page).
Select the "Duplicate Part" action, to create a new copy of the selected part. This will open the "Duplicate Part" form:
@ -67,4 +65,5 @@ When multiple revisions exist for a particular part, you can navigate between re
{{ image("part/part_revision_select.png", "Select part revision") }}
Note that this revision selector is only visible when multiple revisions exist for the part.
!!! info "Revision Selector Visibility"
Note that this revision selector is only visible when multiple revisions exist for the part.

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

@ -547,14 +547,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 +584,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

@ -55,19 +55,12 @@ The admin interface allows *staff* users the ability to directly view / add / ed
#### Access Backend Admin Interface
To access the admin interface, select the "Admin" option from the drop-down user menu in the top-right corner of the screen.
!!! info "Staff Only"
Only users with staff access will be able to see the "Admin" option
To directly access the admin interface, append /admin/ to the InvenTree site URL - e.g. http://localhost:8000/admin/.
An administration panel will be presented as shown below:
{{ image("admin/admin.png", "Admin panel") }}
!!! info "Admin URL"
To directly access the admin interface, append /admin/ to the InvenTree site URL - e.g. http://localhost:8000/admin/
#### View Database Objects
Database objects can be listed and filtered directly. The image below shows an example of displaying existing part categories.

View File

@ -210,6 +210,12 @@ The environment in which the backup was taken does not match the current environ
This warning will not prevent you from restoring the backup but it is recommended to ensure the mentioned issues are resolved before restoring the backup to prevent issues with the restored instance.
#### INVE-W14
**Elevated privileges - Backend**
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-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

@ -50,6 +50,17 @@ Within each role, there are four levels of available permissions:
| **Add** | The *add* permission allows the user to add / create database records associated with the particular role |
| **Delete** | The *delete* permission allows the user to delete / remove database records associated with the particular role |
## Dangerous User Flags
In addition to the above permissions, there are two special flags that can be assigned to a user:
- **Staff** - A user with the *staff* flag is able to access the admin interface, and can trigger dangerous actions that might have a security impact such as changing parsable files on the server (templates / reports / plugins). Some of these actions require the *admin* role to be assigned as well.
- **Superuser** - A user with the *superuser* flag is able to access and change all data and functions of InvenTree. A superuser can modify and access all data that the InvenTree installation / server has access to - including shell access on the server OS itself. This is a very powerful flag, and should be used with caution.
It is strongly recommended to register any users with staff / superuser flags with strong MFA methods to reduce the risk of unauthorized access. These accounts should be used with caution, and should not be used for day-to-day operations.
Practicing account tiering is strongly recommended.
## Admin Interface Permissions
If a user does not have the required permissions to perform a certain action in the admin interface, those options not be displayed.

View File

@ -185,7 +185,6 @@ Proxy configuration can be complex, and any configuration beyond the basic setup
Refer to the [proxy server documentation](./processes.md#proxy-server) for more information.
## Admin Site
Django provides a powerful [administrator interface]({% include "django.html" %}/ref/contrib/admin/) which can be used to manage the InvenTree database. This interface is enabled by default, and available at the `/admin/` URL.
@ -275,7 +274,7 @@ If running with a PostgreSQL database backend, the following additional options
| 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_INTERNAL | database.tcp_keepalives_internal | Internal 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 |
@ -287,6 +286,18 @@ If running with a MySQL database backend, the following additional options are a
| --- | --- | --- | --- |
| INVENTREE_DB_ISOLATION_SERIALIZABLE | database.serializable | Database isolation level configured to "serializable" | False |
### SQLite Settings
!!! warning "SQLite Performance"
SQLite is not recommended for production use, and should only be used for testing or development purposes. If you are using SQLite in production, you may want to adjust the following settings to improve performance.
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 |
## Caching
InvenTree can be configured to use [redis](https://redis.io) as a global cache backend.
@ -401,14 +412,10 @@ It is also possible to use alternative storage backends for static and media fil
| 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_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 |
@ -460,6 +467,17 @@ The login-experience can be altered with the following settings:
Custom authentication backends can be used by specifying them here. These can for example be used to add [LDAP / AD login](https://django-auth-ldap.readthedocs.io/en/latest/) to InvenTree
## Background Worker Options
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 |
## Sentry Integration
The InvenTree server can be integrated with the [sentry.io](https://sentry.io) monitoring service, for error logging and performance tracking.
@ -546,4 +564,4 @@ To override global settings, provide a "dictionary" of settings overrides in the
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| GLOBAL_SETTINGS_OVERRIDES | global_settings_overrides | JSON object containing global settings overrides | *Not specified* |
| INVENTREE_GLOBAL_SETTINGS | global_settings | JSON object containing global settings overrides | *Not specified* |

View File

@ -118,6 +118,9 @@ Extra python packages can be installed by setting the environment variable `SETU
The used database backend can be configured with environment variables (before the first setup) or in the config file after the installation. Check the [configuration section](./config.md#database-options) for more information.
!!! warning "SQLite Performance"
SQLite is not recommended for production use, as it is not designed for high concurrency.
## Moving Data
To change the data storage location, link the new location to `/opt/inventree/data`. A rough outline of steps to achieve this could be:

View File

@ -67,6 +67,10 @@ invoke import-records -c -f data.json
!!! warning "Character Encoding"
If the character encoding of the data file does not exactly match the target database, the import operation may not succeed. In this case, some manual editing of the database JSON file may be required.
```
{{ invoke_commands('import-records --help') }}
```
### Copy Media Files
Any media files (images, documents, etc) that were stored in the original database must be copied to the new database. In a typical InvenTree installation, these files are stored in the `media` subdirectory of the InvenTree data location.
@ -197,3 +201,32 @@ This will load the database records from the backup file into the new database.
### Caveats
The process described here is a *suggested* procedure for migrating between incompatible database versions. However, due to the complexity of database software, there may be unforeseen complications that arise during the process.
## Migrating Plugin Data
Custom plugins may define their own database models, and thus have their own data records stored in the database. If a plugin is being migrated from one InvenTree installation to another, then the plugin data must also be migrated.
To account for this, the `export-records` and `import-records` commands have been designed to also export and import plugin data, in addition to the core InvenTree data.
### Exporting Plugin Data
When running the `export-records` command, any data records associated with plugins will also be exported, and included in the output JSON file.
### Importing Plugin Data
When running the `import-records` command, the import process will also attempt to import any plugin data records contained in the input JSON file. However, for the plugin data to be imported correctly, the following conditions must be met:
1. The plugin *code* must be present in the new InvenTree installation. Any plugins *not* installed will not have their tables created, and thus the import process will fail for those records.
2. The plugin *version* must be the same in both installations. If the plugin version is different, then the database schema may be different, and thus the import process may fail.
3. The InvenTree software version must be the same in both installations. If the InvenTree version is different, then the database schema may be different, and thus the import process may fail.
If all of the above conditions are met, then the plugin data *should* be imported correctly into the new database. To achieve this reliably, the following process steps are implemented in the `import-records` command:
1. The database is cleaned of all existing records (if the `-c` option is used).
2. The core InvenTree database migrations are run to ensure that the core database schema is correct.
3. User auth records are imported into the database
4. Common configuration records (such as global settings) are imported into the database
5. Plugin configuration records (defining which plugins are active) are imported into the database
6. Database migrations are run once more, to ensure that any plugin database schema are correctly initialized
7. The database is checked to ensure that all required apps are present (i.e. all plugins are installed and correctly activated)
8. All remaining records (including plugin data) are imported into the database

View File

@ -16,7 +16,22 @@ InvenTree supports a [number of database backends]({% include "django.html" %}/r
Refer to the [database configuration guide](./config.md#database-options) for more information on selecting and configuring the database backend.
In running InvenTree via [docker compose](./docker_install.md), the database process is managed by the `inventree-db` service which provides a [Postgres docker container](https://hub.docker.com/_/postgres).
If running InvenTree via [docker compose](./docker_install.md), the database process is managed by the `inventree-db` service which provides a [Postgres docker container](https://hub.docker.com/_/postgres).
!!! tip "Postgres Recommended"
We recommend using Postgres as the database backend for InvenTree, as it is a robust and scalable database which is well-suited to production use.
#### SQLite Limitations
!!! warning "SQLite Performance"
SQLite is not recommended for production use, as it is not designed for high concurrency.
While SQLite is supported, it is strongly *not* recommended for a production installation, especially where there may be multiple users accessing the system concurrently. SQLite is designed for low-concurrency applications, and can experience performance issues when multiple users are accessing the database at the same time.
In addition to concurrency issues, there are other structural limitations which exist in SQLite that can prevent operations on large querysets.
If you are using SQLite, you should be aware of these limitations. It is important to ensure that the database file is stored on a fast storage medium (such as an SSD), and that the database options are configured correctly to minimize locking issues. Refer to the [database configuration guide](./config.md#database-options) for more information on configuring SQLite options.
### Web Server
@ -112,6 +127,8 @@ If the background worker process is not running, InvenTree will not be able to p
If the [cache server](#cache-server) is not running, the background worker will be limited to running a single threaded worker. This is because the background worker uses the cache server to manage task locking, and without a global cache server to communicate between processes, concurrency issues can occur.
Additionally, if you are running SQLite as the database backend, the background worker will be limited to a single thread, due to database locking issues which can occur with SQLite when multiple threads are accessing the database concurrently.
### Cache Server
The InvenTree cache server is used to store temporary data which is shared between the InvenTree web server and the background worker processes. The cache server is also used to store task information, and to manage task locking between the background worker processes.
@ -122,7 +139,11 @@ InvenTree uses the [Redis](https://redis.io/) cache server to manage cache data.
!!! info "Redis on Docker"
Docker adds an additional network layer - that might lead to lower performance than bare metal.
To optimize and configure your redis deployment follow the [official docker guide](https://redis.io/docs/getting-started/install-stack/docker/#configuration).
To optimize and configure your redis deployment follow the [official docker guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/docker/).
!!! tip "Enable Cache"
While a redis container is provided in the default configuration, by default it is not enabled in the InvenTree server. You can enable redis cache support by following the [caching configuration guide](./config.md#caching)
### Configuration
Refer to the [background worker configuration options](./config.md#background-worker-options) for more information on configuring the background worker process.

View File

@ -24,7 +24,7 @@ Each *Stock Item* is linked to the following information:
**Last Updated** - Date that the stock quantity was last updated
**Last Stocktake** - Date of most recent stocktake (count) of this item
**Last Stocktake** - Date that this stock item was last counted
**Status** - Status of this stock item

View File

@ -11,14 +11,14 @@ babel==2.18.0 \
# -c src/backend/requirements.txt
# mkdocs-git-revision-date-localized-plugin
# mkdocs-material
backrefs==6.1 \
--hash=sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853 \
--hash=sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1 \
--hash=sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231 \
--hash=sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05 \
--hash=sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0 \
--hash=sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a \
--hash=sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7
backrefs==6.2 \
--hash=sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be \
--hash=sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8 \
--hash=sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b \
--hash=sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7 \
--hash=sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90 \
--hash=sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7 \
--hash=sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49
# via mkdocs-material
beautifulsoup4==4.14.3 \
--hash=sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb \
@ -36,120 +36,136 @@ certifi==2026.2.25 \
# httpcore
# httpx
# requests
charset-normalizer==3.4.4 \
--hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \
--hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \
--hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \
--hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \
--hash=sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc \
--hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \
--hash=sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63 \
--hash=sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d \
--hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \
--hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \
--hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \
--hash=sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505 \
--hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \
--hash=sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af \
--hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \
--hash=sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318 \
--hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \
--hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \
--hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \
--hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \
--hash=sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576 \
--hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \
--hash=sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1 \
--hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \
--hash=sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1 \
--hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \
--hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \
--hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \
--hash=sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88 \
--hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \
--hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \
--hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \
--hash=sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a \
--hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \
--hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \
--hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \
--hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \
--hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \
--hash=sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7 \
--hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \
--hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \
--hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \
--hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \
--hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \
--hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \
--hash=sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2 \
--hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \
--hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \
--hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \
--hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \
--hash=sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf \
--hash=sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6 \
--hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \
--hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \
--hash=sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa \
--hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \
--hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \
--hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \
--hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \
--hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \
--hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \
--hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \
--hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \
--hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \
--hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \
--hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \
--hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \
--hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \
--hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \
--hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \
--hash=sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3 \
--hash=sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9 \
--hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \
--hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \
--hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \
--hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \
--hash=sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50 \
--hash=sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf \
--hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \
--hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \
--hash=sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac \
--hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \
--hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \
--hash=sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c \
--hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \
--hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \
--hash=sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e \
--hash=sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4 \
--hash=sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84 \
--hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \
--hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \
--hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \
--hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \
--hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \
--hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \
--hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \
--hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \
--hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \
--hash=sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074 \
--hash=sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3 \
--hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \
--hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \
--hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \
--hash=sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d \
--hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \
--hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \
--hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \
--hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \
--hash=sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966 \
--hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \
--hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \
--hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \
--hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608
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
# via
# -c src/backend/requirements.txt
# requests
@ -162,9 +178,7 @@ click==8.3.1 \
colorama==0.4.6 \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via
# griffe
# mkdocs-material
# via mkdocs-material
editorconfig==0.17.1 \
--hash=sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82 \
--hash=sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745
@ -173,9 +187,9 @@ essentials==1.1.9 \
--hash=sha256:71ef161e0e27ef77cd6f5fc05e0b8688a575fcab870c01c95940f832e321dfbb \
--hash=sha256:7fbea3a518cbeafe5374fb7e2ea2c15a109e8a7fd1eaab62ae87cbd1b3b1e8d0
# via essentials-openapi
essentials-openapi==1.3.0 \
--hash=sha256:453327a0a847a431133f4472ced7e4a9180bf667437049b57381ddf88079e886 \
--hash=sha256:9c2a88531e2c70c565d5b526d74043941e46f60c114f7a0e3ae91e9e6bef4dae
essentials-openapi==1.4.0 \
--hash=sha256:578c81501ccf6d18c0839d60636214fbd051f0ef37f1d207d4e3c92de2aac008 \
--hash=sha256:86d879c32734248ad52482a90ee89a32883bce348b4edd323c01556b505b45b9
# via neoteroi-mkdocs
ghp-import==2.1.0 \
--hash=sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619 \
@ -189,9 +203,8 @@ gitpython==3.1.46 \
--hash=sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f \
--hash=sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058
# via mkdocs-git-revision-date-localized-plugin
griffe==1.15.0 \
--hash=sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3 \
--hash=sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea
griffelib==2.0.0 \
--hash=sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f
# via mkdocstrings-python
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
@ -369,15 +382,15 @@ mkdocs==1.6.1 \
# mkdocs-simple-hooks
# mkdocstrings
# neoteroi-mkdocs
mkdocs-autorefs==1.4.3 \
--hash=sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9 \
--hash=sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75
mkdocs-autorefs==1.4.4 \
--hash=sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089 \
--hash=sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197
# via
# mkdocstrings
# mkdocstrings-python
mkdocs-get-deps==0.2.0 \
--hash=sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c \
--hash=sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134
mkdocs-get-deps==0.2.2 \
--hash=sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1 \
--hash=sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650
# via mkdocs
mkdocs-git-revision-date-localized-plugin==1.5.1 \
--hash=sha256:2b0239455cd84784dd87ac8dfc9253fe4b2dd35e102696f21b5d34e2175981c6 \
@ -391,9 +404,9 @@ mkdocs-macros-plugin==1.5.0 \
--hash=sha256:12aa45ce7ecb7a445c66b9f649f3dd05e9b92e8af6bc65e4acd91d26f878c01f \
--hash=sha256:c10fabd812bf50f9170609d0ed518e54f1f0e12c334ac29141723a83c881dd6f
# via -r docs/requirements.in
mkdocs-material==9.7.3 \
--hash=sha256:37ebf7b4788c992203faf2e71900be3c197c70a4be9b0d72aed537b08a91dd9d \
--hash=sha256:e5f0a18319699da7e78c35e4a8df7e93537a888660f61a86bd773a7134798f22
mkdocs-material==9.7.6 \
--hash=sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69 \
--hash=sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba
# via -r docs/requirements.in
mkdocs-material-extensions==1.3.1 \
--hash=sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443 \
@ -417,9 +430,9 @@ mkdocstrings[python]==1.0.3 \
# via
# -r docs/requirements.in
# mkdocstrings-python
mkdocstrings-python==2.0.1 \
--hash=sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90 \
--hash=sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732
mkdocstrings-python==2.0.3 \
--hash=sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12 \
--hash=sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8
# via mkdocstrings
neoteroi-mkdocs==1.2.0 \
--hash=sha256:58e25cb1b9db093ffa8d12bdb33264bf567cac30fb964b56e0a493efa749ad6e \
@ -436,27 +449,27 @@ paginate==0.5.7 \
--hash=sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945 \
--hash=sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591
# via mkdocs-material
pathspec==1.0.1 \
--hash=sha256:8870061f22c58e6d83463cfce9a7dd6eca0512c772c1001fb09ac64091816721 \
--hash=sha256:e2769b508d0dd47b09af6ee2c75b2744a2cb1f474ae4b1494fd6a1b7a841613c
pathspec==1.0.4 \
--hash=sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645 \
--hash=sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723
# via
# mkdocs
# mkdocs-macros-plugin
platformdirs==4.9.2 \
--hash=sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd \
--hash=sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291
platformdirs==4.9.4 \
--hash=sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934 \
--hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868
# via
# -c src/backend/requirements.txt
# mkdocs-get-deps
pygments==2.19.2 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via
# mkdocs-material
# rich
pymdown-extensions==10.20 \
--hash=sha256:5c73566ab0cf38c6ba084cb7c5ea64a119ae0500cce754ccb682761dfea13a52 \
--hash=sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f
pymdown-extensions==10.21.2 \
--hash=sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638 \
--hash=sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc
# via
# mkdocs-material
# mkdocs-mermaid2-plugin
@ -554,9 +567,9 @@ pyyaml-env-tag==1.1 \
--hash=sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04 \
--hash=sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff
# via mkdocs
requests==2.32.5 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
requests==2.33.0 \
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via
# -c src/backend/requirements.txt
# mkdocs-macros-plugin
@ -566,9 +579,9 @@ rich==14.3.3 \
--hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \
--hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b
# via neoteroi-mkdocs
setuptools==82.0.0 \
--hash=sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb \
--hash=sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0
setuptools==82.0.1 \
--hash=sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9 \
--hash=sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb
# via
# -c src/backend/requirements.txt
# mkdocs-mermaid2-plugin
@ -579,13 +592,13 @@ six==1.17.0 \
# -c src/backend/requirements.txt
# jsbeautifier
# python-dateutil
smmap==5.0.2 \
--hash=sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5 \
--hash=sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e
smmap==5.0.3 \
--hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \
--hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f
# via gitdb
soupsieve==2.8.1 \
--hash=sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350 \
--hash=sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434
soupsieve==2.8.3 \
--hash=sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349 \
--hash=sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95
# via beautifulsoup4
super-collections==0.6.2 \
--hash=sha256:0c8d8abacd9fad2c7c1c715f036c29f5db213f8cac65f24d45ecba12b4da187a \

View File

@ -108,11 +108,12 @@ root = ["src/backend/InvenTree"]
unresolved-reference="ignore" # 21 # see https://github.com/astral-sh/ty/issues/220
unresolved-attribute="ignore" # 505 # need Plugin Mixin typing
call-non-callable="ignore" # 8 ##
invalid-assignment="ignore" # 17 # need to wait for better django field stubs
invalid-method-override="ignore" # 99
invalid-return-type="ignore" # 22 ##
possibly-missing-attribute="ignore" # 25 # https://github.com/astral-sh/ty/issues/164
unknown-argument="ignore" # 3 # need to wait for better django field stubs
invalid-argument-type="ignore" # 49
possibly-unbound-attribute="ignore" # 25 # https://github.com/astral-sh/ty/issues/164
unknown-argument="ignore" # 3 # need to wait for betterdjango field stubs
invalid-assignment="ignore" # 17 # need to wait for betterdjango field stubs
no-matching-overload="ignore" # 3 # need to wait for betterdjango field stubs
[tool.coverage.run]
@ -139,3 +140,4 @@ django_find_project = false
pythonpath = ["src/backend/InvenTree"]
DJANGO_SETTINGS_MODULE = "InvenTree.settings"
python_files = ["test*.py",]
timeout = "120"

View File

@ -1,5 +1,6 @@
# Files generated during unit testing
_testfolder/
_tests_report*.txt
# Playwright files for CI
InvenTree/static/img/playwright*.png

View File

@ -423,23 +423,19 @@ class BulkOperationMixin:
def get_bulk_queryset(self, request):
"""Return a queryset based on the selection made in the request.
Selection can be made by providing either:
- items: A list of primary key values
- filters: A dictionary of filter values
Selection can be made by providing a list of primary key values,
which will be used to filter the queryset.
"""
model = self.serializer_class.Meta.model
items = request.data.pop('items', None)
filters = request.data.pop('filters', None)
all_filter = request.GET.get('all', None)
queryset = model.objects.all()
# Return the base queryset for this model
queryset = self.get_queryset()
if not items and not filters and all_filter is None:
if not items and all_filter is None:
raise ValidationError({
'non_field_errors': _(
'List of items or filters must be provided for bulk operation'
'List of items must be provided for bulk operation'
)
})
@ -457,19 +453,6 @@ class BulkOperationMixin:
'non_field_errors': _('Invalid items list provided')
})
if filters:
if type(filters) is not dict:
raise ValidationError({
'non_field_errors': _('Filters must be provided as a dict')
})
try:
queryset = queryset.filter(**filters)
except Exception:
raise ValidationError({
'non_field_errors': _('Invalid filters provided')
})
if all_filter and not helpers.str2bool(all_filter):
raise ValidationError({
'non_field_errors': _('All filter must only be used with true')
@ -507,7 +490,7 @@ class BulkCreateMixin:
if unique_create_fields := getattr(self, 'unique_create_fields', None):
existing = collections.defaultdict(list)
for idx, item in enumerate(data):
key = tuple(item[v] for v in unique_create_fields)
key = tuple(item[v] for v in list(unique_create_fields)) # type: ignore[not-subscriptable]
existing[key].append(idx)
unique_errors = [[] for _ in range(len(data))]
@ -594,17 +577,24 @@ class BulkUpdateMixin(BulkOperationMixin):
n = queryset.count()
instance_data = []
with transaction.atomic():
# Perform object update
# Note that we do not perform a bulk-update operation here,
# as we want to trigger any custom post_save methods on the model
# Run validation first
for instance in queryset:
serializer = self.get_serializer(instance, data=data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response({'success': f'Updated {n} items'}, status=200)
instance_data.append(serializer.data)
return Response(
{'success': f'Updated {n} items', 'items': instance_data}, status=200
)
class ParameterListMixin:

View File

@ -1,11 +1,59 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 461
INVENTREE_API_VERSION = 476
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
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
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 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
v466 -> 2026-03-17 : https://github.com/inventree/InvenTree/pull/11525
- SalesOrderShipmentComplete endpoint now returns a task ID which can be used to track the progress of the shipment completion process
v465 -> 2026-03-16 : https://github.com/inventree/InvenTree/pull/11529/
- BuildOrderAutoAllocate endpoint now returns a task ID which can be used to track the progress of the auto-allocation process
- BuildOrderConsume endpoint now returns a task ID which can be used to track the progress of the stock consumption process
v464 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11527
- Add API endpoint for monitoring the progress of a particular background task
v463 -> 2026-03-12 : https://github.com/inventree/InvenTree/pull/11499
- Allow "bulk update" actions against StockItem endpoint
v462 -> 2026-03-12 : https://github.com/inventree/InvenTree/pull/11497
- Allow "ScheduledTask" API endpoint to be filtered by "name" field
v461 -> 2026-03-10 : https://github.com/inventree/InvenTree/pull/11479
- Adds option to copy parameters when duplicating an order via the API
@ -304,12 +352,6 @@ v381 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10132
v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10135
- Fixes "issued_by" filter for the BuildOrder list API endpoint
v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10132
- Refactor the "return stock item" API endpoint to align with other stock adjustment actions
v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10135
- Fixes "issued_by" filter for the BuildOrder list API endpoint
v379 -> 2025-08-04 : https://github.com/inventree/InvenTree/pull/10124
- Removes "PartStocktakeReport" model and associated API endpoints
- Remove "last_stocktake" field from the Part model

View File

@ -191,7 +191,7 @@ def load_config_data(set_cache: bool = False) -> map | None:
if CONFIG_DATA is not None and not set_cache:
return CONFIG_DATA
import yaml
import yaml.parser
cfg_file = get_config_file()

View File

@ -66,7 +66,7 @@ def log_error(
data = error_data
else:
try:
formatted_exception = traceback.format_exception(kind, info, data) # type: ignore[no-matching-overload]
formatted_exception = traceback.format_exception(kind, info, data)
data = '\n'.join(formatted_exception)
except AttributeError:
data = 'No traceback information available'

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
@ -21,19 +22,19 @@ from django.http import StreamingHttpResponse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
import bleach
import bleach.css_sanitizer
import bleach.sanitizer
import nh3
import structlog
from bleach import clean
from djmoney.money import Money
from PIL import Image
from stdimage.models import StdImageField, StdImageFieldFile
from common.currency import currency_code_default
from .setting.storages import StorageBackends
from .settings import MEDIA_URL, STATIC_URL
from InvenTree.sanitizer import (
DEAFAULT_ATTRS,
DEFAULT_CSS,
DEFAULT_PROTOCOLS,
DEFAULT_TAGS,
)
logger = structlog.get_logger('inventree')
@ -186,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):
@ -199,7 +199,7 @@ def regenerate_imagefile(_file, _name: str):
_name: Name of the variation (e.g. 'thumbnail', 'preview')
"""
name = _file.field.attr_class.get_variation_name(_file.name, _name)
return ImageFieldFile(_file.instance, _file, name) # type: ignore
return ImageFieldFile(_file.instance, _file, name) # ty:ignore[too-many-positional-arguments]
def image2name(img_obj: StdImageField, do_preview: bool, do_thumbnail: bool):
@ -226,11 +226,18 @@ 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):
"""Test if an image file is indeed an image."""
def TestIfImage(img) -> bool:
"""Test if an image file is indeed an image.
Arguments:
img: A file-like object
Returns:
True if the file is a valid image, False otherwise
"""
try:
Image.open(img).verify()
return True
@ -248,6 +255,13 @@ def getBlankThumbnail():
return getStaticUrl('img/blank_image.thumbnail.png')
def checkStaticFile(*args) -> bool:
"""Check if a file exists in the static storage."""
static_storage = StaticFilesStorage()
fn = Path(*args)
return static_storage.exists(str(fn))
def getLogoImage(as_file=False, custom=True):
"""Return the InvenTree logo image, or a custom logo if available."""
if custom and settings.CUSTOM_LOGO:
@ -311,7 +325,7 @@ def TestIfImageURL(url):
]
def str2bool(text, test=True):
def str2bool(text, test=True) -> bool:
"""Test if a string 'looks' like a boolean value.
Args:
@ -881,13 +895,13 @@ def clean_decimal(number):
def strip_html_tags(value: str, raise_error=True, field_name=None):
"""Strip HTML tags from an input string using the bleach library.
"""Strip HTML tags from an input string using the nh3 library.
If raise_error is True, a ValidationError will be thrown if HTML tags are detected
"""
value = str(value).strip()
cleaned = clean(value, strip=True, tags=[], attributes=[])
cleaned = nh3.clean(value, tags=frozenset())
# Add escaped characters back in
replacements = {'&gt;': '>', '&lt;': '<', '&amp;': '&'}
@ -947,34 +961,32 @@ def clean_markdown(value: str) -> str:
output_format='html',
)
# Bleach settings
whitelist_tags = markdownify_settings.get(
'WHITELIST_TAGS', bleach.sanitizer.ALLOWED_TAGS
)
whitelist_attrs = markdownify_settings.get(
'WHITELIST_ATTRS', bleach.sanitizer.ALLOWED_ATTRIBUTES
)
whitelist_styles = markdownify_settings.get(
'WHITELIST_STYLES', bleach.css_sanitizer.ALLOWED_CSS_PROPERTIES
)
# nh3 sanitizer settings
whitelist_tags = markdownify_settings.get('WHITELIST_TAGS', DEFAULT_TAGS)
whitelist_attrs = markdownify_settings.get('WHITELIST_ATTRS', DEAFAULT_ATTRS)
whitelist_styles = markdownify_settings.get('WHITELIST_STYLES', DEFAULT_CSS)
whitelist_protocols = markdownify_settings.get(
'WHITELIST_PROTOCOLS', bleach.sanitizer.ALLOWED_PROTOCOLS
'WHITELIST_PROTOCOLS', DEFAULT_PROTOCOLS
)
strip = markdownify_settings.get('STRIP', True)
css_sanitizer = bleach.css_sanitizer.CSSSanitizer(
allowed_css_properties=whitelist_styles
)
cleaner = bleach.Cleaner(
tags=whitelist_tags,
attributes=whitelist_attrs,
css_sanitizer=css_sanitizer,
protocols=whitelist_protocols,
strip=strip,
)
# Convert bleach-style attributes (list or dict) to nh3-compatible dict format
if isinstance(whitelist_attrs, (list, tuple, set, frozenset)):
attrs_dict = {'*': set(whitelist_attrs)}
elif isinstance(whitelist_attrs, dict):
attrs_dict = {tag: set(allowed) for tag, allowed in whitelist_attrs.items()}
else:
attrs_dict = None
# Clean the HTML content (for comparison). This must be the same as the original content
clean_html = cleaner.clean(html)
clean_html = nh3.clean(
html,
tags=set(whitelist_tags),
attributes=attrs_dict,
url_schemes=set(whitelist_protocols),
filter_style_properties=set(whitelist_styles),
link_rel=None,
strip_comments=True,
)
if html != clean_html:
raise ValidationError(_('Data contains prohibited markdown content'))

View File

@ -8,6 +8,7 @@ import structlog
from allauth.account.models import EmailAddress
import InvenTree.ready
import InvenTree.tasks as tasks
from common.models import Priority, issue_mail
logger = structlog.get_logger('inventree')
@ -98,7 +99,7 @@ def send_email(
)
return False, 'INVE-W7: no from_email or DEFAULT_FROM_EMAIL specified'
InvenTree.tasks.offload_task(
tasks.offload_task(
issue_mail,
subject=subject,
body=body,

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}')
@ -288,6 +351,8 @@ def getModelsWithMixin(mixin_class) -> list:
models_with_mixin = [
x for x in db_models if x is not None and issubclass(x, mixin_class)
]
# sort to make resulting list deterministic (and easier to test)
models_with_mixin.sort(key=lambda x: x._meta.label_lower)
# Store the result in the session cache
set_session_cache(cache_key, models_with_mixin)

View File

@ -0,0 +1,17 @@
"""Helpers for logging integrations."""
from django.dispatch import receiver
import structlog
from django_structlog import signals
@receiver(signals.update_failure_response)
@receiver(signals.bind_extra_request_finished_metadata)
def add_request_id_to_response(response, logger, **kwargs):
"""Add the request ID to the response header, so that it can be traced through logs.
source: https://django-structlog.readthedocs.io/en/latest/how_tos.html#bind-request-id-to-response-s-header
"""
context = structlog.contextvars.get_merged_contextvars(logger)
response['X-InvenTree-ReqId'] = context['request_id']

View File

@ -0,0 +1,29 @@
"""Custom management command to list all installed apps.
This is used to determine which apps are installed,
including any apps which are defined for plugins.
"""
from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""List all installed apps."""
def handle(self, *args, **kwargs):
"""List all installed apps.
Note that this function outputs in a particular format,
which is expected by the calling code in tasks.py
"""
from django.apps import apps
app_list = []
for app in apps.get_app_configs():
app_list.append(app.name)
app_list.sort()
self.stdout.write(f'Installed Apps: {len(app_list)}')
self.stdout.write('>>> ' + ','.join(app_list) + ' <<<')

View File

@ -99,7 +99,7 @@ class Command(BaseCommand):
if kwargs['include_items']:
icons[icon]['items'].append({
'model': model.__name__.lower(),
'id': item.id, # type: ignore
'id': item.id,
})
self.stdout.write(f'Writing icon map for {len(icons.keys())} icons')

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

@ -5,7 +5,7 @@ from django.http import Http404
from django.urls import reverse
import structlog
from rest_framework import exceptions, serializers
from rest_framework import exceptions, permissions, serializers
from rest_framework.fields import empty
from rest_framework.metadata import SimpleMetadata
from rest_framework.request import clone_request
@ -131,10 +131,26 @@ class InvenTreeMetadata(SimpleMetadata):
# Remove any HTTP methods that the user does not have permission for
for method, permission in rolemap.items():
# general model / role permission
result = check_user_permission(user, self.model, permission) or (
role_required and check_user_role(user, role_required, permission)
)
# check if simple IsAuthenticated permission class is used
if not result:
result = (
view.permission_classes
and len(view.permission_classes) == 1
and any(
perm
in [
permissions.IsAuthenticated,
InvenTree.permissions.IsAuthenticatedOrReadScope,
]
for perm in view.permission_classes
)
)
if method in actions and not result:
del actions[method]

View File

@ -19,6 +19,11 @@ from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting
from InvenTree.cache import create_session_cache, delete_session_cache
from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller
from InvenTree.version import (
inventreeApiVersion,
inventreePythonVersion,
inventreeVersion,
)
from users.models import ApiToken
logger = structlog.get_logger('inventree')
@ -393,3 +398,15 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
# All checks passed
return None
class InvenTreeVersionHeaderMiddleware(MiddlewareMixin):
"""Middleware to add the InvenTree version header to all responses."""
def process_response(self, request, response):
"""Add the InvenTree version header to the response."""
response['X-InvenTree-Version'] = inventreeVersion()
response['X-InvenTree-API'] = inventreeApiVersion()
response['X-InvenTree-Python'] = inventreePythonVersion()
response['X-InvenTree-Installer'] = inventreeInstaller()
return response

View File

@ -19,7 +19,7 @@ from InvenTree.serializers import FilterableSerializerMixin
class CleanMixin:
"""Model mixin class which cleans inputs using the Mozilla bleach tools."""
"""Model mixin class which cleans inputs using nh3."""
# Define a list of field names which will *not* be cleaned
SAFE_FIELDS = []
@ -52,16 +52,7 @@ class CleanMixin:
return Response(serializer.data)
def clean_string(self, field: str, data: str) -> str:
"""Clean / sanitize a single input string.
Note that this function will *allow* orphaned <>& characters,
which would normally be escaped by bleach.
Nominally, the only thing that will be "cleaned" will be HTML tags
Ref: https://github.com/mozilla/bleach/issues/192
"""
"""Clean / sanitize a single input string."""
cleaned = data
# By default, newline characters are removed
@ -101,7 +92,7 @@ class CleanMixin:
def clean_data(self, data: dict) -> dict:
"""Clean / sanitize data.
This uses Mozilla's bleach under the hood to disable certain html tags by
This uses nh3 under the hood to disable certain html tags by
encoding them - this leads to script tags etc. to not work.
The results can be longer then the input; might make some character combinations
`ugly`. Prevents XSS on the server-level.

View File

@ -1410,32 +1410,22 @@ def after_failed_task(sender, instance: Task, created: bool, **kwargs):
"""Callback when a new task failure log is generated."""
from django.conf import settings
from InvenTree.exceptions import log_error
max_attempts = int(settings.Q_CLUSTER.get('max_attempts', 5))
n = instance.attempt_count
# Only notify once the maximum number of attempts has been reached
if not instance.success and n >= max_attempts:
try:
url = InvenTree.helpers_model.construct_absolute_url(
reverse(
'admin:django_q_failure_change', kwargs={'object_id': instance.pk}
)
)
except (ValueError, NoReverseMatch):
url = ''
# Create a new Error object associated with this failed task
# This will, in turn, trigger a notification to staff users via the Error post_save signal
# Function name
f = instance.func
notify_staff_users_of_error(
instance,
'inventree.task_failure',
{
'failure': instance,
'name': _('Task Failure'),
'message': _(f"Background worker task '{f}' failed after {n} attempts"),
'link': url,
},
log_error(
'task_failure',
scope='worker',
error_name='Task Failure',
error_info=f"Task '{instance.pk}' failed after {n} attempts",
error_data=str(instance.result) if instance.result else '',
)
@ -1495,7 +1485,7 @@ class InvenTreeImageMixin(models.Model):
def rename_image(self, filename):
"""Rename the uploaded image file using the IMAGE_RENAME function."""
return self.IMAGE_RENAME(filename) # type: ignore
return self.IMAGE_RENAME(filename)
image = StdImageField(
upload_to=rename_image,

View File

@ -376,7 +376,7 @@ def auth_exempt(view_func):
def wrapped_view(*args, **kwargs):
return view_func(*args, **kwargs)
wrapped_view.auth_exempt = True # type:ignore[unresolved-attribute]
wrapped_view.auth_exempt = True
return wraps(view_func)(wrapped_view)

View File

@ -119,16 +119,29 @@ def isGeneratingSchema():
'collectstatic',
'makemessages',
'wait_for_db',
'list_apps',
'gunicorn',
'sqlflush',
'qcluster',
'check',
'shell',
'help',
]
if any(cmd in sys.argv for cmd in excluded_commands):
return False
if 'schema' in sys.argv:
included_commands = [
'schema',
'spectactular',
# schema adjacent calls
'export_settings_definitions',
'export_tags',
'export_filters',
'export_report_context',
]
if any(cmd in sys.argv for cmd in included_commands):
return True
# This is a very inefficient call - so we only use it as a last resort
@ -175,11 +188,46 @@ def isInMainThread():
return not isInWorkerThread()
def readOnlyCommands():
"""Return a list of read-only management commands which should not trigger database writes."""
return [
'help',
'check',
'shell',
'sqlflush',
'list_apps',
'wait_for_db',
'spectactular',
'makemessages',
'collectstatic',
'showmigrations',
'compilemessages',
]
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())
def canAppAccessDatabase(
allow_test: bool = False, allow_plugins: bool = False, allow_shell: bool = False
):
"""Returns True if the apps.py file can access database records.
Arguments:
allow_test: If True, override checks and allow database access during testing mode
allow_plugins: If True, override checks and allow database access during plugin loading
allow_shell: If True, override checks and allow database access during shell sessions
There are some circumstances where we don't want the ready function in apps.py
to touch the database
"""
@ -188,7 +236,7 @@ def canAppAccessDatabase(
return False
# Prevent database access if we are importing data
if isImportingData():
if not allow_plugins and isImportingData():
return False
# Prevent database access if we are rebuilding data
@ -202,13 +250,13 @@ def canAppAccessDatabase(
# If any of the following management commands are being executed,
# prevent custom "on load" code from running!
excluded_commands = [
'check',
'createsuperuser',
'wait_for_db',
'makemessages',
'compilemessages',
'spectactular',
'createsuperuser',
'collectstatic',
'makemessages',
'spectactular',
'wait_for_db',
'check',
]
if not allow_shell:

View File

@ -1,7 +1,66 @@
"""Functions to sanitize user input files."""
from bleach import clean
from bleach.css_sanitizer import CSSSanitizer
import nh3
# Allowed CSS properties for SVG sanitization (combines general CSS and SVG-specific properties)
_SVG_ALLOWED_CSS_PROPERTIES = frozenset([
# General CSS (matching bleach's original ALLOWED_CSS_PROPERTIES)
'azimuth',
'background-color',
'border-bottom-color',
'border-collapse',
'border-color',
'border-left-color',
'border-right-color',
'border-top-color',
'clear',
'color',
'cursor',
'direction',
'display',
'elevation',
'float',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'height',
'letter-spacing',
'line-height',
'overflow',
'pause',
'pause-after',
'pause-before',
'pitch',
'pitch-range',
'richness',
'speak',
'speak-header',
'speak-numeral',
'speak-punctuation',
'speech-rate',
'stress',
'text-align',
'text-decoration',
'text-indent',
'unicode-bidi',
'vertical-align',
'voice-family',
'volume',
'white-space',
'width',
# SVG-specific CSS (matching bleach's ALLOWED_SVG_PROPERTIES)
'fill',
'fill-opacity',
'fill-rule',
'stroke',
'stroke-linecap',
'stroke-linejoin',
'stroke-opacity',
'stroke-width',
])
ALLOWED_ELEMENTS_SVG = [
'a',
@ -184,6 +243,74 @@ ALLOWED_ATTRIBUTES_SVG = [
'style',
]
# Default allowlists (matching bleach's original defaults)
# TODO: I do not see us needing a bunch of these but I do not want to introduce a breaking change; we might want to narroy this down with the next breaking change
DEFAULT_TAGS = frozenset([
'a',
'abbr',
'acronym',
'b',
'blockquote',
'code',
'em',
'i',
'li',
'ol',
'strong',
'ul',
])
DEAFAULT_ATTRS = {'a': {'href', 'title'}, 'abbr': {'title'}, 'acronym': {'title'}}
DEFAULT_CSS = frozenset([
'azimuth',
'background-color',
'border-bottom-color',
'border-collapse',
'border-color',
'border-left-color',
'border-right-color',
'border-top-color',
'clear',
'color',
'cursor',
'direction',
'display',
'elevation',
'float',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'height',
'letter-spacing',
'line-height',
'overflow',
'pause',
'pause-after',
'pause-before',
'pitch',
'pitch-range',
'richness',
'speak',
'speak-header',
'speak-numeral',
'speak-punctuation',
'speech-rate',
'stress',
'text-align',
'text-decoration',
'text-indent',
'unicode-bidi',
'vertical-align',
'voice-family',
'volume',
'white-space',
'width',
])
# TODO: We might want to respect the setting EXTRA_URL_SCHEMES here but that would be breaking
DEFAULT_PROTOCOLS = frozenset(['http', 'https', 'mailto'])
def sanitize_svg(
file_data,
@ -206,13 +333,16 @@ def sanitize_svg(
if isinstance(file_data, bytes):
file_data = file_data.decode('utf-8')
cleaned = clean(
# nh3 requires attributes as dict[str, set[str]]; convert from list (allowed for all elements)
attrs_dict = {elem: set(attributes) for elem in elements}
cleaned = nh3.clean(
file_data,
tags=elements,
attributes=attributes,
strip=strip,
tags=set(elements),
attributes=attrs_dict,
filter_style_properties=_SVG_ALLOWED_CSS_PROPERTIES,
strip_comments=strip,
css_sanitizer=CSSSanitizer(),
link_rel=None,
)
return cleaned

View File

@ -1,14 +1,13 @@
"""Serializers used in various InvenTree apps."""
import os
from collections import OrderedDict
from copy import deepcopy
from decimal import Decimal
from typing import Any, Optional
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.files.storage import default_storage
from django.db import models
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
@ -33,8 +32,6 @@ from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers import str2bool
from InvenTree.helpers_model import getModelsWithMixin
from .setting.storages import StorageBackends
# region path filtering
class FilterableSerializerField:
@ -689,9 +686,7 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
if not value:
return None
if settings.STORAGE_TARGET == StorageBackends.S3:
return str(value.url)
return os.path.join(str(settings.MEDIA_URL), str(value))
return default_storage.url(str(value))
class InvenTreeImageSerializerField(serializers.ImageField):
@ -705,9 +700,7 @@ class InvenTreeImageSerializerField(serializers.ImageField):
if not value:
return None
if settings.STORAGE_TARGET == StorageBackends.S3:
return str(value.url)
return os.path.join(str(settings.MEDIA_URL), str(value))
return default_storage.url(str(value))
class InvenTreeDecimalField(serializers.FloatField):

View File

@ -0,0 +1,151 @@
"""Configuration settings specific to a particular database backend."""
import structlog
from InvenTree.config import get_boolean_setting, get_setting
logger = structlog.get_logger('inventree')
def set_db_options(engine: str, db_options: dict):
"""Update database options based on the specified database backend.
Arguments:
engine: The database engine (e.g. 'sqlite3', 'postgresql', etc.)
db_options: The database options dictionary to update
"""
logger.debug('Setting database options: %s', engine)
if 'postgres' in engine:
set_postgres_options(db_options)
elif 'mysql' in engine:
set_mysql_options(db_options)
elif 'sqlite' in engine:
set_sqlite_options(db_options)
else:
raise ValueError(f'Unknown database engine: {engine}')
def set_postgres_options(db_options: dict):
"""Set database options specific to postgres backend."""
from django.db.backends.postgresql.psycopg_any import IsolationLevel
# Connection timeout
if 'connect_timeout' not in db_options:
# The DB server is in the same data center, it should not take very
# long to connect to the database server
# # seconds, 2 is minimum allowed by libpq
db_options['connect_timeout'] = int(
get_setting('INVENTREE_DB_TIMEOUT', 'database.timeout', 2)
)
# Setup TCP keepalive
# DB server is in the same DC, it should not become unresponsive for
# very long. With the defaults below we wait 5 seconds for the network
# issue to resolve itself. If that doesn't happen, whatever happened
# is probably fatal and no amount of waiting is going to fix it.
# # 0 - TCP Keepalives disabled; 1 - enabled
if 'keepalives' not in db_options:
db_options['keepalives'] = int(
get_setting('INVENTREE_DB_TCP_KEEPALIVES', 'database.tcp_keepalives', 1)
)
# Seconds after connection is idle to send keep alive
if 'keepalives_idle' not in db_options:
db_options['keepalives_idle'] = int(
get_setting(
'INVENTREE_DB_TCP_KEEPALIVES_IDLE', 'database.tcp_keepalives_idle', 1
)
)
# Seconds after missing ACK to send another keep alive
if 'keepalives_interval' not in db_options:
db_options['keepalives_interval'] = int(
get_setting(
'INVENTREE_DB_TCP_KEEPALIVES_INTERVAL',
'database.tcp_keepalives_interval',
'1',
)
)
# Number of missing ACKs before we close the connection
if 'keepalives_count' not in db_options:
db_options['keepalives_count'] = int(
get_setting(
'INVENTREE_DB_TCP_KEEPALIVES_COUNT',
'database.tcp_keepalives_count',
'5',
)
)
# # Milliseconds for how long pending data should remain unacked
# by the remote server
# TODO: Supported starting in PSQL 11
# "tcp_user_timeout": int(os.getenv("PGTCP_USER_TIMEOUT", "1000"),
# Postgres's default isolation level is Read Committed which is
# normally fine, but most developers think the database server is
# actually going to do Serializable type checks on the queries to
# protect against simultaneous changes.
# https://www.postgresql.org/docs/devel/transaction-iso.html
# https://docs.djangoproject.com/en/3.2/ref/databases/#isolation-level
if 'isolation_level' not in db_options:
serializable = get_boolean_setting(
'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False
)
db_options['isolation_level'] = (
IsolationLevel.SERIALIZABLE
if serializable
else IsolationLevel.READ_COMMITTED
)
def set_mysql_options(db_options: dict):
"""Set database options specific to mysql backend."""
# TODO TCP time outs and keepalives
# MariaDB's default isolation level is Repeatable Read which is
# normally fine, but most developers think the database server is
# actually going to Serializable type checks on the queries to
# protect against simultaneous changes.
# https://mariadb.com/kb/en/mariadb-transactions-and-isolation-levels-for-sql-server-users/#changing-the-isolation-level
# https://docs.djangoproject.com/en/3.2/ref/databases/#mysql-isolation-level
if 'isolation_level' not in db_options:
serializable = get_boolean_setting(
'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False
)
db_options['isolation_level'] = (
'serializable' if serializable else 'read committed'
)
def set_sqlite_options(db_options: dict):
"""Set database options specific to sqlite backend.
References:
- https://docs.djangoproject.com/en/5.0/ref/databases/#sqlite-notes
- https://docs.djangoproject.com/en/6.0/ref/databases/#database-is-locked-errors
"""
import InvenTree.ready
# Specify minimum timeout behavior for SQLite connections
if 'timeout' not in db_options:
db_options['timeout'] = int(
get_setting('INVENTREE_DB_TIMEOUT', 'database.timeout', 10)
)
# Specify the transaction mode for the database
# For the backend worker thread, IMMEDIATE mode is used,
# it has been determined to provide better protection against database locks in the worker thread
db_options['transaction_mode'] = (
'IMMEDIATE' if InvenTree.ready.isInWorkerThread() else 'DEFERRED'
)
# SQLite's default isolation level is Serializable due to SQLite's
# single writer implementation. Presumably as a result of this, it is
# not possible to implement any lower isolation levels in SQLite.
# https://www.sqlite.org/isolation.html
if get_boolean_setting('INVENTREE_DB_WAL_MODE', 'database.wal_mode', True):
# Specify that we want to use Write-Ahead Logging (WAL) mode for SQLite databases, as this allows for better concurrency and performance
db_options['init_command'] = 'PRAGMA journal_mode=WAL;'

View File

@ -33,7 +33,7 @@ from InvenTree.version import checkMinPythonVersion, inventreeCommitHash
from users.oauth2_scopes import oauth2_scopes
from . import config
from .setting import locales, markdown, spectacular, storages
from .setting import db_backend, locales, markdown, spectacular, storages
try:
import django_stubs_ext
@ -194,10 +194,18 @@ PLUGINS_MANDATORY = get_setting(
'INVENTREE_PLUGINS_MANDATORY', 'plugins_mandatory', typecast=list, default_value=[]
)
if PLUGINS_MANDATORY:
logger.info('Mandatory plugins: %s', PLUGINS_MANDATORY)
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
@ -372,6 +380,7 @@ MIDDLEWARE = CONFIG.get(
'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching
'InvenTree.middleware.InvenTreeHostSettingsMiddleware', # Ensuring correct hosting/security settings
'django_structlog.middlewares.RequestMiddleware', # Structured logging
'InvenTree.middleware.InvenTreeVersionHeaderMiddleware',
],
)
@ -483,7 +492,7 @@ if LDAP_AUTH: # pragma: no cover
)
AUTH_LDAP_USER_SEARCH = django_auth_ldap.config.LDAPSearch(
get_setting('INVENTREE_LDAP_SEARCH_BASE_DN', 'ldap.search_base_dn'),
ldap.SCOPE_SUBTREE, # type: ignore[unresolved-attribute]
ldap.SCOPE_SUBTREE,
str(
get_setting(
'INVENTREE_LDAP_SEARCH_FILTER_STR',
@ -519,7 +528,7 @@ if LDAP_AUTH: # pragma: no cover
)
AUTH_LDAP_GROUP_SEARCH = django_auth_ldap.config.LDAPSearch(
get_setting('INVENTREE_LDAP_GROUP_SEARCH', 'ldap.group_search'),
ldap.SCOPE_SUBTREE, # type: ignore[unresolved-attribute]
ldap.SCOPE_SUBTREE,
f'(objectClass={AUTH_LDAP_GROUP_OBJECT_CLASS})',
)
AUTH_LDAP_GROUP_TYPE_CLASS = get_setting(
@ -720,108 +729,8 @@ db_options = db_config.get('OPTIONS', db_config.get('options'))
if db_options is None:
db_options = {}
# Specific options for postgres backend
if 'postgres' in DB_ENGINE: # pragma: no cover
from django.db.backends.postgresql.psycopg_any import ( # type: ignore[unresolved-import]
IsolationLevel,
)
# Connection timeout
if 'connect_timeout' not in db_options:
# The DB server is in the same data center, it should not take very
# long to connect to the database server
# # seconds, 2 is minimum allowed by libpq
db_options['connect_timeout'] = int(
get_setting('INVENTREE_DB_TIMEOUT', 'database.timeout', 2)
)
# Setup TCP keepalive
# DB server is in the same DC, it should not become unresponsive for
# very long. With the defaults below we wait 5 seconds for the network
# issue to resolve itself. It it that doesn't happen whatever happened
# is probably fatal and no amount of waiting is going to fix it.
# # 0 - TCP Keepalives disabled; 1 - enabled
if 'keepalives' not in db_options:
db_options['keepalives'] = int(
get_setting('INVENTREE_DB_TCP_KEEPALIVES', 'database.tcp_keepalives', 1)
)
# Seconds after connection is idle to send keep alive
if 'keepalives_idle' not in db_options:
db_options['keepalives_idle'] = int(
get_setting(
'INVENTREE_DB_TCP_KEEPALIVES_IDLE', 'database.tcp_keepalives_idle', 1
)
)
# Seconds after missing ACK to send another keep alive
if 'keepalives_interval' not in db_options:
db_options['keepalives_interval'] = int(
get_setting(
'INVENTREE_DB_TCP_KEEPALIVES_INTERVAL',
'database.tcp_keepalives_internal',
'1',
)
)
# Number of missing ACKs before we close the connection
if 'keepalives_count' not in db_options:
db_options['keepalives_count'] = int(
get_setting(
'INVENTREE_DB_TCP_KEEPALIVES_COUNT',
'database.tcp_keepalives_count',
'5',
)
)
# # Milliseconds for how long pending data should remain unacked
# by the remote server
# TODO: Supported starting in PSQL 11
# "tcp_user_timeout": int(os.getenv("PGTCP_USER_TIMEOUT", "1000"),
# Postgres's default isolation level is Read Committed which is
# normally fine, but most developers think the database server is
# actually going to do Serializable type checks on the queries to
# protect against simultaneous changes.
# https://www.postgresql.org/docs/devel/transaction-iso.html
# https://docs.djangoproject.com/en/3.2/ref/databases/#isolation-level
if 'isolation_level' not in db_options:
serializable = get_boolean_setting(
'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False
)
db_options['isolation_level'] = (
IsolationLevel.SERIALIZABLE
if serializable
else IsolationLevel.READ_COMMITTED
)
# Specific options for MySql / MariaDB backend
elif 'mysql' in DB_ENGINE: # pragma: no cover
# TODO TCP time outs and keepalives
# MariaDB's default isolation level is Repeatable Read which is
# normally fine, but most developers think the database server is
# actually going to Serializable type checks on the queries to
# protect against siumltaneous changes.
# https://mariadb.com/kb/en/mariadb-transactions-and-isolation-levels-for-sql-server-users/#changing-the-isolation-level
# https://docs.djangoproject.com/en/3.2/ref/databases/#mysql-isolation-level
if 'isolation_level' not in db_options:
serializable = get_boolean_setting(
'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False
)
db_options['isolation_level'] = (
'serializable' if serializable else 'read committed'
)
# Specific options for sqlite backend
elif 'sqlite' in DB_ENGINE:
# TODO: Verify timeouts are not an issue because no network is involved for SQLite
# SQLite's default isolation level is Serializable due to SQLite's
# single writer implementation. Presumably as a result of this, it is
# not possible to implement any lower isolation levels in SQLite.
# https://www.sqlite.org/isolation.html
pass
# 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
@ -930,10 +839,15 @@ GLOBAL_CACHE_ENABLED = is_global_cache_enabled()
CACHES = {'default': get_cache_config(GLOBAL_CACHE_ENABLED)}
_q_worker_timeout = int(
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,
)
# Prevent running multiple background workers if global cache is disabled
# This is to prevent scheduling conflicts due to the lack of a shared cache
@ -943,22 +857,39 @@ BACKGROUND_WORKER_COUNT = (
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)
)
# 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
Q_CLUSTER = {
'name': 'InvenTree',
'label': 'Background Tasks',
'workers': BACKGROUND_WORKER_COUNT,
'timeout': _q_worker_timeout,
'retry': max(120, _q_worker_timeout + 30),
'max_attempts': int(
get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5)
),
'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,
'sync': SYNC_TASKS,
'poll': 1.5,
}

View File

@ -49,7 +49,7 @@ def check_provider(provider):
if not app:
return False
if allauth.app_settings.SITES_ENABLED: # type: ignore[unresolved-attribute]
if allauth.app_settings.SITES_ENABLED:
# At least one matching site must be specified
if not app.sites.exists():
logger.error('SocialApp %s has no sites configured', app)

View File

@ -159,15 +159,71 @@ def record_task_success(task_name: str):
set_global_setting(f'_{task_name}_SUCCESS', datetime.now().isoformat(), None)
def check_existing_task(taskname, group: str, *args, **kwargs) -> Optional[str]:
"""Test if an identical task is already registered with the worker.
This will only return true if the task name, group, args and kwargs all match an existing task.
Arguments:
taskname: The name of the task to check for, in the format 'app.module.function'
group: The group that the task belongs to
*args: Positional arguments to match
**kwargs: Keyword arguments to match
Returns:
Optional[str]: The ID of the matching task, if found, otherwise None
"""
from django_q.models import OrmQ
task_id = None
# Iterate through all available tasks, with the most recent first
for task in OrmQ.objects.all().order_by('-id'):
if task.func() != taskname and task.task['func'] != taskname:
# Task does not match
continue
if task.group() != group:
# Group does not match
continue
if task.args() != args:
# Task args do not match
continue
if task.kwargs() != kwargs:
# Task kwargs do not match
continue
task_id = task.task_id()
break
return task_id
def offload_task(
taskname, *args, force_async=False, force_sync=False, **kwargs
) -> bool:
taskname,
*args,
force_async: bool = False,
force_sync: bool = False,
check_duplicates: bool = True,
**kwargs,
) -> str | bool:
"""Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once!
If workers are not running or force_sync flag, is set then the task is ran synchronously.
Arguments:
taskname: The name of the task to be run, in the format 'app.module.function'
*args: Positional arguments to be passed to the task function
force_async: If True, force the task to be offloaded (even if workers are not running)
force_sync: If True, force the task to be run synchronously (even if workers are running)
check_duplicates: If True, check for existing identical tasks before offloading
**kwargs: Keyword arguments to be passed to the task function
Returns:
bool: True if the task was offloaded (or ran), False otherwise
str | bool: Task ID if the task was offloaded, True if ran synchronously, False otherwise
"""
from InvenTree.exceptions import log_error
@ -198,11 +254,23 @@ def offload_task(
force_sync = True
if force_async or (is_worker_running() and not force_sync):
# Before offloading, check if a duplicate task exists
if not force_sync and check_duplicates:
if task_id := check_existing_task(taskname, group, *args, **kwargs):
logger.debug(
"Skipping duplicate task '%s' with ID '%s'", taskname, task_id
)
return task_id
# Running as asynchronous task
try:
task = AsyncTask(taskname, *args, group=group, **kwargs)
with tracer.start_as_current_span(f'async worker: {taskname}'):
task.run()
# Return the ID of the offloaded task, so that it can be tracked if needed
return task.id
except ImportError:
raise_warning(f"WARNING: '{taskname}' not offloaded - Function not found")
return False
@ -265,6 +333,40 @@ def offload_task(
return True
def get_queued_task(task_id: str):
"""Find the task in the queue, if it exists.
Note that the OrmQ table does NOT keep the task ID as a database field,
it is instead stored in the payload data.
If there are a large number of pending tasks, this query may be inefficient,
but there is no other way to find a queued task by ID.
"""
offset = 0
limit = 500
if not task_id:
# Return early if no task ID was provided
return None
task_id = str(task_id)
from django_q.models import OrmQ
while True:
queued_tasks = OrmQ.objects.all().order_by('id')[offset : offset + limit]
if not queued_tasks:
break
for task in queued_tasks:
if task.task_id() == task_id:
return task
offset += limit
# No matching task was discovered
return None
@dataclass()
class ScheduledTask:
"""A scheduled task.
@ -286,7 +388,7 @@ class ScheduledTask:
QUARTERLY: str = 'Q'
YEARLY: str = 'Y'
TYPE: tuple[str] = (MINUTES, HOURLY, DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY) # type: ignore[invalid-assignment]
TYPE: tuple[str] = (MINUTES, HOURLY, DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY)
class TaskRegister:

View File

@ -278,8 +278,7 @@ class BulkDeleteTests(InvenTreeAPITestCase):
response = self.delete(url, {}, expected_code=400)
self.assertIn(
'List of items or filters must be provided for bulk operation',
str(response.data),
'List of items must be provided for bulk operation', str(response.data)
)
# DELETE with invalid 'items'
@ -287,11 +286,6 @@ class BulkDeleteTests(InvenTreeAPITestCase):
self.assertIn('Items must be provided as a list', str(response.data))
# DELETE with invalid 'filters'
response = self.delete(url, {'filters': [1, 2, 3]}, expected_code=400)
self.assertIn('Filters must be provided as a dict', str(response.data))
class SearchTests(InvenTreeAPITestCase):
"""Unit tests for global search endpoint."""
@ -574,7 +568,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
self.assertIn('License file not found at', str(log.output))
with TemporaryDirectory() as tmp: # type: ignore[no-matching-overload]
with TemporaryDirectory() as tmp:
sample_file = Path(tmp, 'temp.txt')
sample_file.write_text('abc', 'utf-8')

View File

@ -10,7 +10,7 @@ from django.db.utils import NotSupportedError
from django.test import TestCase
from django.utils import timezone
from django_q.models import Schedule, Task
from django_q.models import OrmQ, Schedule, Task
from error_report.models import Error
import InvenTree.tasks
@ -234,11 +234,8 @@ class InvenTreeTaskTests(PluginRegistryMixin, TestCase):
msg = NotificationMessage.objects.last()
self.assertEqual(msg.name, 'Task Failure')
self.assertEqual(
msg.message,
"Background worker task 'InvenTree.tasks.failed_task' failed after 10 attempts",
)
self.assertEqual(msg.name, 'Server Error')
self.assertEqual(msg.message, 'An error has been logged by the server.')
def test_delete_old_emails(self):
"""Test the delete_old_emails task."""
@ -297,3 +294,74 @@ class InvenTreeTaskTests(PluginRegistryMixin, TestCase):
)
msg.timestamp = timestamp
msg.save()
def test_duplicate_tasks(self):
"""Test for task duplication."""
# Start with a blank slate
OrmQ.objects.all().delete()
# Add some unique tasks
for idx in range(10):
InvenTree.tasks.offload_task(
f'dummy_module.dummy_function_{idx}', force_async=True
)
self.assertEqual(OrmQ.objects.count(), 10)
# Add some duplicate tasks
for _idx in range(10):
InvenTree.tasks.offload_task(
'dummy_module.dummy_function_x',
1,
2,
3,
animal='cat',
vegetable='carrot',
force_async=True,
)
# Only 1 extra task should have been added
self.assertEqual(OrmQ.objects.count(), 11)
# Add some more duplicate tasks, but ignore duplication checks
for _idx in range(10):
InvenTree.tasks.offload_task(
'dummy_module.dummy_function_y',
1,
2,
3,
animal='dog',
vegetable='yam',
force_async=True,
check_duplicates=False,
)
# 10 extra tasks should have been added
self.assertEqual(OrmQ.objects.count(), 21)
# Add more tasks, which are *not* duplicates based on args
for idx in range(10):
InvenTree.tasks.offload_task(
'dummy_module.dummy_function',
1,
idx,
3,
animal='cat',
vegetable='carrot',
force_async=True,
)
# Add more tasks, which are *not* duplicates based on kwargs
for idx in range(10):
InvenTree.tasks.offload_task(
'dummy_module.dummy_function',
1,
2,
3,
animal='cat',
vegetable=f'vegetable_{idx}',
force_async=True,
)
# 20 more tasks should have been added
self.assertEqual(OrmQ.objects.count(), 41)

View File

@ -692,23 +692,26 @@ 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
with self.assertRaises(TypeError):
helpers.getMediaUrl('xx/yy.png') # type: ignore
helpers.getMediaUrl('xx/yy.png')
# Correct usage
part = Part().image
self.assertEqual(
helpers.getMediaUrl(StdImageFieldFile(part, part, 'xx/yy.png')), # type: ignore
helpers.getMediaUrl(StdImageFieldFile(part, part, 'xx/yy.png')), # ty:ignore[too-many-positional-arguments]
'/media/xx/yy.png',
)
@ -1604,11 +1607,11 @@ class SanitizerTest(TestCase):
def test_svg_sanitizer(self):
"""Test that SVGs are sanitized accordingly."""
valid_string = """<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="svg2" height="400" width="400">{0}
<path id="path1" d="m -151.78571,359.62883 v 112.76373 l 97.068507,-56.04253 V 303.14815 Z" style="fill:#ddbc91;"></path>
<path id="path1" d="m -151.78571,359.62883 v 112.76373 l 97.068507,-56.04253 V 303.14815 Z" style="fill:#ddbc91"></path>
</svg>"""
dangerous_string = valid_string.format('<script>alert();</script>')
# Test that valid string
# Test that valid string passes through unchanged
self.assertEqual(valid_string, sanitize_svg(valid_string))
# Test that invalid string is cleaned

View File

@ -4,7 +4,7 @@ import base64
import logging
from typing import Optional
from opentelemetry import metrics, trace # type: ignore[import]
from opentelemetry import metrics, trace
from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor

View File

@ -20,6 +20,7 @@ import build.api
import common.api
import company.api
import importer.api
import InvenTree.logging # noqa: F401 - ensure logging handlers are registered
import machine.api
import order.api
import part.api

View File

@ -289,8 +289,10 @@ def inventreeBranch():
return ' '.join(branch.splitlines())
if main_branch is None:
return None
return main_branch.decode('utf-8')
return None # pragma: no cover - branch information may not be available in all environments
return main_branch.decode(
'utf-8'
) # pragma: no cover - branch information may not be available in all environments
def inventreeTarget():

View File

@ -11,12 +11,13 @@ import django_filters.rest_framework.filters as rest_filters
from django_filters.rest_framework.filterset import FilterSet
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import serializers, status
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.response import Response
import build.models as build_models
import build.serializers
import common.models
import common.serializers
import part.models as part_models
import stock.models as stock_models
import stock.serializers
@ -145,8 +146,8 @@ class BuildFilter(FilterSet):
def filter_overdue(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are overdue."""
if str2bool(value):
return queryset.filter(Build.OVERDUE_FILTER)
return queryset.exclude(Build.OVERDUE_FILTER)
return queryset.filter(Build.get_overdue_filter())
return queryset.exclude(Build.get_overdue_filter())
assigned_to_me = rest_filters.BooleanFilter(
label=_('Assigned to me'), method='filter_assigned_to_me'
@ -662,6 +663,13 @@ class BuildLineDetail(BuildLineMixin, OutputOptionsMixin, RetrieveUpdateDestroyA
class BuildOrderContextMixin:
"""Mixin class which adds build order as serializer context variable."""
def get_build(self):
"""Return the Build object associated with this API endpoint."""
try:
return Build.objects.get(pk=self.kwargs.get('pk', None))
except (ValueError, Build.DoesNotExist):
raise NotFound(_('Build not found'))
def get_serializer_context(self):
"""Add extra context information to the endpoint serializer."""
ctx = super().get_serializer_context()
@ -670,8 +678,8 @@ class BuildOrderContextMixin:
ctx['to_complete'] = True
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except Exception:
ctx['build'] = self.get_build()
except NotFound:
pass
return ctx
@ -764,6 +772,37 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
queryset = Build.objects.none()
serializer_class = build.serializers.BuildAutoAllocationSerializer
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override the POST method to handle auto allocation task.
As this is offloaded to the background task,
we return information about the background task which is performing the auto allocation operation.
"""
from build.tasks import auto_allocate_build
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# Offload the task to the background worker
task_id = offload_task(
auto_allocate_build,
build.pk,
location=data.get('location', None),
exclude_location=data.get('exclude_location', None),
interchangeable=data['interchangeable'],
substitutes=data['substitutes'],
optional_items=data['optional_items'],
item_type=data.get('item_type', 'untracked'),
group='build',
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildAllocate(BuildOrderContextMixin, CreateAPI):
"""API endpoint to allocate stock items to a build order.
@ -786,6 +825,39 @@ class BuildConsume(BuildOrderContextMixin, CreateAPI):
queryset = Build.objects.none()
serializer_class = build.serializers.BuildConsumeSerializer
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override the POST method to handle consume task.
As this is offloaded to the background task,
we return information about the background task which is performing the consume operation.
"""
from build.tasks import consume_build_stock
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# Extract the information we need to consume build stock
items = data.get('items', [])
lines = data.get('lines', [])
notes = data.get('notes', '')
# Offload the task to the background worker
task_id = offload_task(
consume_build_stock,
build.pk,
lines=[line['build_line'].pk for line in lines],
items={item['build_item'].pk: item['quantity'] for item in items},
user_id=self.request.user.pk,
notes=notes,
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildIssue(BuildOrderContextMixin, CreateAPI):
"""API endpoint for issuing a BuildOrder."""

View File

@ -1,7 +1,7 @@
"""Build database model definitions."""
import decimal
from typing import Optional
from typing import Optional, TypedDict
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
@ -50,7 +50,7 @@ from stock.status_codes import StockHistoryCode, StockStatus
logger = structlog.get_logger('inventree')
class BuildReportContext(report.mixins.BaseReportContext):
class BuildReportContext(report.mixins.BaseReportContext, TypedDict):
"""Context for the Build model.
Attributes:
@ -132,11 +132,14 @@ class Build(
TRACKED = 'tracked' # Tracked BOM items
UNTRACKED = 'untracked' # Untracked BOM items
OVERDUE_FILTER = (
Q(status__in=BuildStatusGroups.ACTIVE_CODES)
& ~Q(target_date=None)
& Q(target_date__lte=InvenTree.helpers.current_date())
)
@classmethod
def get_overdue_filter(cls):
"""Filter for determining if a build order is overdue."""
return (
Q(status__in=BuildStatusGroups.ACTIVE_CODES)
& ~Q(target_date=None)
& Q(target_date__lte=InvenTree.helpers.current_date())
)
# Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
@ -465,7 +468,7 @@ class Build(
bool: Is the build overdue
"""
query = Build.objects.filter(pk=self.pk)
query = query.filter(Build.OVERDUE_FILTER)
query = query.filter(Build.get_overdue_filter())
return query.exists()
@ -1036,7 +1039,7 @@ class Build(
lines = lines.exclude(bom_item__consumable=True)
lines = lines.annotate(allocated=annotate_allocated_quantity())
for build_line in lines: # type: ignore[non-iterable]
for build_line in lines:
reduce_by = build_line.allocated - build_line.quantity
if reduce_by <= 0:
@ -1509,10 +1512,10 @@ class Build(
unallocated_quantity -= quantity
except (ValidationError, serializers.ValidationError) as exc:
# Catch model errors and re-throw as DRF errors
# Re-raise with a Django-compatible validation payload
raise ValidationError(
exc.message, detail=serializers.as_serializer_error(exc)
)
serializers.as_serializer_error(exc)
) from exc
if unallocated_quantity <= 0:
# We have now fully-allocated this BomItem - no need to continue!
@ -1692,7 +1695,7 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
instance.update_build_line_items()
class BuildLineReportContext(report.mixins.BaseReportContext):
class BuildLineReportContext(report.mixins.BaseReportContext, TypedDict):
"""Context for the BuildLine model.
Attributes:
@ -1742,6 +1745,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
"""Return the API URL used to access this model."""
return reverse('api-build-line-list')
# type
def report_context(self) -> BuildLineReportContext:
"""Generate custom report context for this BuildLine object."""
return {

View File

@ -21,7 +21,6 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.serializers import ValidationError
import build.tasks
import common.filters
import common.settings
import company.serializers
@ -38,7 +37,6 @@ from InvenTree.serializers import (
NotesFieldMixin,
enable_filter,
)
from InvenTree.tasks import offload_task
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
from stock.serializers import (
@ -51,7 +49,6 @@ from users.serializers import OwnerSerializer, UserSerializer
from .models import Build, BuildItem, BuildLine
from .status_codes import BuildStatus
from .tasks import consume_build_item, consume_build_line
class BuildSerializer(
@ -168,7 +165,8 @@ class BuildSerializer(
queryset = queryset.annotate(
overdue=Case(
When(
Build.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
Build.get_overdue_filter(),
then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField()),
)
@ -1128,27 +1126,6 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
help_text=_('Select item type to auto-allocate'),
)
def save(self):
"""Perform the auto-allocation step."""
import InvenTree.tasks
data = self.validated_data
build_order = self.context['build']
if not InvenTree.tasks.offload_task(
build.tasks.auto_allocate_build,
build_order.pk,
location=data.get('location', None),
exclude_location=data.get('exclude_location', None),
interchangeable=data['interchangeable'],
substitutes=data['substitutes'],
optional_items=data['optional_items'],
item_type=data.get('item_type', 'untracked'),
group='build',
):
raise ValidationError(_('Failed to start auto-allocation task'))
class BuildItemSerializer(
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
@ -1846,46 +1823,3 @@ class BuildConsumeSerializer(serializers.Serializer):
raise ValidationError(_('At least one item or line must be provided'))
return data
@transaction.atomic
def save(self):
"""Perform the stock consumption step."""
data = self.validated_data
request = self.context.get('request')
notes = data.get('notes', '')
# We may be passed either a list of BuildItem or BuildLine instances
items = data.get('items', [])
lines = data.get('lines', [])
with transaction.atomic():
# Process the provided BuildItem objects
for item in items:
build_item = item['build_item']
quantity = item['quantity']
if build_item.install_into:
# If the build item is tracked into an output, we do not consume now
# Instead, it gets consumed when the output is completed
continue
# Offload a background task to consume this BuildItem
offload_task(
consume_build_item,
build_item.pk,
quantity,
notes=notes,
user_id=request.user.pk if request else None,
)
# Process the provided BuildLine objects
for line in lines:
build_line = line['build_line']
# Offload a background task to consume this BuildLine
offload_task(
consume_build_line,
build_line.pk,
notes=notes,
user_id=request.user.pk if request else None,
)

View File

@ -2,8 +2,10 @@
from datetime import timedelta
from decimal import Decimal
from typing import Optional
from django.contrib.auth.models import User
from django.db import transaction
from django.utils.translation import gettext_lazy as _
import structlog
@ -27,61 +29,53 @@ def auto_allocate_build(build_id: int, **kwargs):
"""Run auto-allocation for a specified BuildOrder."""
from build.models import Build
build_order = Build.objects.filter(pk=build_id).first()
if not build_order:
logger.warning(
'Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist',
build_id,
)
return
build_order = Build.objects.get(pk=build_id)
build_order.auto_allocate_stock(**kwargs)
@tracer.start_as_current_span('consume_build_item')
def consume_build_item(
item_id: str, quantity, notes: str = '', user_id: int | None = None
@tracer.start_as_current_span('consume_build_stock')
def consume_build_stock(
build_id: int,
lines: Optional[list[int]] = None,
items: Optional[dict] = None,
user_id: int | None = None,
**kwargs,
):
"""Consume stock against a particular BuildOrderLineItem allocation."""
from build.models import BuildItem
"""Consume stock for the specified BuildOrder.
item = BuildItem.objects.filter(pk=item_id).first()
Arguments:
build_id: The ID of the BuildOrder to consume stock for
lines: Optional list of BuildLine IDs to consume
items: Optional dict of BuildItem IDs (and quantities)to consume
user_id: The ID of the user who initiated the stock consumption
"""
from build.models import Build, BuildItem, BuildLine
if not item:
logger.warning(
'Could not consume stock for BuildItem <%s> - BuildItem does not exist',
item_id,
)
return
build = Build.objects.get(pk=build_id)
user = User.objects.filter(pk=user_id).first() if user_id else None
item.complete_allocation(
quantity=quantity,
notes=notes,
user=User.objects.filter(pk=user_id).first() if user_id else None,
)
lines = lines or []
items = items or {}
notes = kwargs.pop('notes', '')
# Extract the relevant BuildLine and BuildItem objects
with transaction.atomic():
# Consume each of the specified BuildLine objects
for line_id in lines:
if build_line := BuildLine.objects.filter(pk=line_id, build=build).first():
for item in build_line.allocations.all():
item.complete_allocation(
quantity=item.quantity, notes=notes, user=user
)
@tracer.start_as_current_span('consume_build_line')
def consume_build_line(line_id: int, notes: str = '', user_id: int | None = None):
"""Consume stock against a particular BuildOrderLineItem."""
from build.models import BuildLine
line_item = BuildLine.objects.filter(pk=line_id).first()
if not line_item:
logger.warning(
'Could not consume stock for LineItem <%s> - LineItem does not exist',
line_id,
)
return
for item in line_item.allocations.all():
item.complete_allocation(
quantity=item.quantity,
notes=notes,
user=User.objects.filter(pk=user_id).first() if user_id else None,
)
# Consume each of the specified BuildItem objects
for item_id, quantity in items.items():
if build_item := BuildItem.objects.filter(
pk=item_id, build_line__build=build
).first():
build_item.complete_allocation(
quantity=quantity, notes=notes, user=user
)
@tracer.start_as_current_span('complete_build_allocations')
@ -89,7 +83,7 @@ def complete_build_allocations(build_id: int, user_id: int):
"""Complete build allocations for a specified BuildOrder."""
from build.models import Build
build_order = Build.objects.filter(pk=build_id).first()
build_order = Build.objects.get(pk=build_id)
if user_id:
try:
@ -103,13 +97,6 @@ def complete_build_allocations(build_id: int, user_id: int):
else:
user = None
if not build_order:
logger.warning(
'Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist',
build_id,
)
return
build_order.complete_allocations(user)

View File

@ -970,12 +970,12 @@ class BuildAllocationTest(BuildAPITest):
url = reverse('api-build-auto-allocate', kwargs={'pk': build.pk})
# Allocate only 'untracked' items - this should not allocate our tracked item
self.post(url, data={'item_type': 'untracked'})
self.post(url, data={'item_type': 'untracked'}, expected_code=200)
self.assertEqual(N, BuildItem.objects.count())
# Allocate 'tracked' items - this should allocate our tracked item
self.post(url, data={'item_type': 'tracked'})
self.post(url, data={'item_type': 'tracked'}, expected_code=200)
# A new BuildItem should have been created
self.assertEqual(N + 1, BuildItem.objects.count())
@ -1564,12 +1564,13 @@ class BuildLineTests(BuildAPITest):
# Filter by 'available' status
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
response = self.get(url, data={'available': True}, max_query_time=15)
# TODO: This needs to be addressed in the future, as 25 seconds is an unacceptably long time for a query to take in testing
response = self.get(url, data={'available': True}, max_query_time=25)
n_t = len(response.data)
self.assertGreater(n_t, 0)
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
response = self.get(url, data={'available': False}, max_query_time=15)
response = self.get(url, data={'available': False}, max_query_time=25)
n_f = len(response.data)
self.assertGreater(n_f, 0)
@ -1735,7 +1736,7 @@ class BuildConsumeTest(BuildAPITest):
'lines': [{'build_line': line.pk} for line in self.build.build_lines.all()]
}
self.post(url, data, expected_code=201)
self.post(url, data, expected_code=200)
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3)
@ -1758,7 +1759,7 @@ class BuildConsumeTest(BuildAPITest):
]
}
self.post(url, data, expected_code=201)
self.post(url, data, expected_code=200)
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3)

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

@ -17,8 +17,8 @@ from django.views.decorators.csrf import csrf_exempt
import django_filters.rest_framework.filters as rest_filters
import django_q.models
import django_q.tasks
from django_filters.rest_framework.filterset import FilterSet
from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
@ -115,7 +115,7 @@ class WebhookView(CsrfExemptMixin, APIView):
# process data
message = self.webhook.save_data(payload, headers, request)
if self.run_async:
async_task(self._process_payload, message.id)
django_q.tasks.async_task(self._process_payload, message.id)
else:
self._process_result(
self.webhook.process_payload(message, payload, headers), message
@ -564,13 +564,29 @@ class ErrorMessageDetail(RetrieveUpdateDestroyAPI):
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
class BackgroundTaskDetail(APIView):
"""Detail view for a single background task."""
permission_classes = [IsAuthenticatedOrReadScope]
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def get(self, request, task_id, *args, **kwargs):
"""Fetch information regarding a particular background task ID."""
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BackgroundTaskOverview(APIView):
"""Provides an overview of the background task queue status."""
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
serializer_class = None
@extend_schema(responses={200: common.serializers.TaskOverviewSerializer})
@extend_schema(
operation_id='background_task_overview',
responses={200: common.serializers.TaskOverviewSerializer},
)
def get(self, request, fmt=None):
"""Return information about the current status of the background task queue."""
import django_q.models as q_models
@ -608,7 +624,7 @@ class ScheduledTaskList(ListAPI):
ordering_fields = ['pk', 'func', 'last_run', 'next_run']
search_fields = ['func']
search_fields = ['func', 'name']
def get_queryset(self):
"""Return annotated queryset."""
@ -1150,6 +1166,14 @@ 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."""
@ -1162,6 +1186,22 @@ class DataOutputEndpointMixin:
serializer_class = common.serializers.DataOutputSerializer
permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self):
"""Return the set of DataOutput objects which the user has permission to view."""
queryset = super().get_queryset()
try:
user = self.request.user
except AttributeError:
raise PermissionDenied('User information is not available')
# Allow staff users access to all DataOutput objects
if user.is_staff:
return queryset
# All other users are limited to viewing their own DataOutput objects
return queryset.filter(user=user)
class DataOutputList(DataOutputEndpointMixin, BulkDeleteMixin, ListAPI):
"""List view for DataOutput objects."""
@ -1396,6 +1436,9 @@ common_api_urls = [
name='api-scheduled-task-list',
),
path('failed/', FailedTaskList.as_view(), name='api-failed-task-list'),
path(
'<str:task_id>/', BackgroundTaskDetail.as_view(), name='api-task-detail'
),
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
]),
),

View File

@ -230,6 +230,6 @@ def get_price(
quantity = decimal.Decimal(f'{quantity}')
if pb_found:
cost = pb_cost * quantity
cost = decimal.Decimal(pb_cost) * quantity
return InvenTree.helpers.normalize(cost + instance.base_cost)
return None

View File

@ -10,6 +10,7 @@ import json
import math
import os
import uuid
from collections import OrderedDict
from datetime import timedelta, timezone
from email.utils import make_msgid
from enum import Enum
@ -85,8 +86,8 @@ class RenderMeta(enums.ChoicesType):
return []
class RenderChoices(models.TextChoices, metaclass=RenderMeta): # type: ignore
"""Class for creating enumerated string choices for schema rendering."""
class RenderChoices(models.TextChoices, metaclass=RenderMeta):
"""Class for creating enumerated string choices for schema rendering.""" # ty:ignore[conflicting-metaclass]
class MetaMixin(models.Model):
@ -363,9 +364,15 @@ class BaseInvenTreeSetting(models.Model):
# Specify any "default" values which are not in the database
settings_definition = settings_definition or cls.SETTINGS
all_settings = OrderedDict()
for key, setting in settings_definition.items():
if key.upper() not in settings:
settings[key.upper()] = cls(
# If the setting is already in the database, use that value
if key.upper() in settings:
all_settings[key] = settings[key.upper()]
else:
all_settings[key.upper()] = cls(
key=key.upper(),
value=cls.get_setting_default(key, **filters),
**filters,
@ -373,10 +380,10 @@ class BaseInvenTreeSetting(models.Model):
# remove any hidden settings
if exclude_hidden and setting.get('hidden', False):
del settings[key.upper()]
del all_settings[key.upper()]
# format settings values and remove protected
for key, setting in settings.items():
for key, setting in all_settings.items():
validator = cls.get_setting_validator(key, **filters)
if cls.is_protected(key, **filters) and setting.value != '':
@ -389,7 +396,7 @@ class BaseInvenTreeSetting(models.Model):
except ValueError:
setting.value = cls.get_setting_default(key, **filters)
return settings
return all_settings
@classmethod
def allValues(
@ -1084,7 +1091,7 @@ class BaseInvenTreeSetting(models.Model):
return self.__class__.validator_is_bool(validator)
def as_bool(self):
def as_bool(self) -> bool:
"""Return the value of this setting converted to a boolean value.
Warning: Only use on values where is_bool evaluates to true!
@ -2313,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

@ -42,7 +42,7 @@ class SettingsValueField(serializers.Field):
"""Return the object instance, not the attribute value."""
return instance
def to_representation(self, instance: common_models.InvenTreeSetting) -> str:
def to_representation(self, instance: common_models.InvenTreeSetting):
"""Return the value of the setting.
Protected settings are returned as '***'
@ -381,7 +381,7 @@ 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[instance]}
return {'key': instance, **self.instance.get(instance)}
class NotesImageSerializer(InvenTreeModelSerializer):
@ -457,7 +457,7 @@ class FlagSerializer(serializers.Serializer):
data = {'key': instance, 'state': flag_state(instance, request=request)}
if request and request.user.is_superuser:
data['conditions'] = self.instance[instance]
data['conditions'] = self.instance.get(instance)
return data
@ -522,6 +522,78 @@ class ErrorMessageSerializer(InvenTreeModelSerializer):
read_only_fields = ['when', 'info', 'data', 'path', 'pk']
class TaskDetailSerializer(serializers.Serializer):
"""Serializer for a background task detail."""
task_id = serializers.CharField(read_only=True)
exists = serializers.BooleanField(read_only=True)
pending = serializers.BooleanField(read_only=True)
complete = serializers.BooleanField(read_only=True)
success = serializers.BooleanField(read_only=True)
http_status = serializers.IntegerField(read_only=True)
@classmethod
def from_task(cls, task_id: str | bool | None) -> 'TaskDetailSerializer':
"""Create a TaskDetailSerializer instance from a django_q Task.
Arguments:
task_id: The ID of the task to retrieve details for.
Returns:
An instance of TaskDetailSerializer with the task details.
Notes:
- If the provided task_id is None, the task has not been run, or has errored out
- If the provided task_id is a boolean, the task has been run synchronously, and the boolean value indicates success or failure
- If the provided task_id is a string, the task has been offloaded to the background worker, and the details can be from the database
"""
from InvenTree.tasks import get_queued_task
if task_id is None or type(task_id) is bool:
# If the task_id is a boolean, the task has been run synchronously
return cls({
'task_id': '',
'exists': False,
'pending': False,
'complete': task_id is not None,
'success': False if task_id is None else bool(task_id),
'http_status': 404 if task_id is None else 200,
})
# A non-boolean result indicates that the task has been offloaded to the background worker
success = django_q.models.Success.objects.filter(id=task_id).first()
failure = django_q.models.Failure.objects.filter(id=task_id).first()
task = (
success
or failure
or django_q.models.Task.objects.filter(id=task_id).first()
)
queued = False
exists = bool(success or failure or task)
if not exists:
# If the task has not been started yet, it may be present in the queue
queued = bool(get_queued_task(task_id))
complete = bool(success) or bool(failure)
# Determine the http_status code for the task
# - 200: Task exists and has been completed
# - 404: Task does not exist
http_status = 200 if exists or queued else 404
return cls({
'task_id': task_id,
'exists': exists or queued,
'pending': queued,
'complete': complete,
'success': bool(success),
'http_status': http_status,
})
class TaskOverviewSerializer(serializers.Serializer):
"""Serializer for background task overview."""

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

@ -26,6 +26,7 @@ class SettingsKeyType(TypedDict, total=False):
validator: Validation function/list of functions for the setting (optional, default: None, e.g: bool, int, str, MinValueValidator, ...)
default: Default value or function that returns default value (optional)
choices: Function that returns or value of list[tuple[str: key, str: display value]] (optional)
model_filters: Filters to apply when querying the associated model (optional)
hidden: Hide this setting from settings page (optional)
before_save: Function that gets called after save with *args, **kwargs (optional)
after_save: Function that gets called after save with *args, **kwargs (optional)
@ -42,6 +43,7 @@ class SettingsKeyType(TypedDict, total=False):
validator: Callable | list[Callable] | tuple[Callable]
default: Callable | Any
choices: list[tuple[str, str]] | Callable[[], list[tuple[str, str]]]
model_filters: dict[str, Any]
hidden: bool
before_save: Callable[..., None]
after_save: Callable[..., None]

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)
@ -53,6 +58,12 @@ def set_global_setting(key, value, change_user=None, create=True, **kwargs):
kwargs['change_user'] = change_user
kwargs['create'] = create
if get_global_setting(key, create=False, cache=False) == value:
logger.debug(
f'Global setting "{key}" already has the desired value, no update needed'
)
return True
return InvenTreeSetting.set_setting(key, value, **kwargs)

View File

@ -6,6 +6,44 @@ import common.models
from InvenTree.unit_test import InvenTreeAPITestCase
class DataOutputAPITests(InvenTreeAPITestCase):
"""API tests for the DataOutput endpoint."""
roles = 'all'
def setUp(self):
"""Set up some test data for DataOutput API testing."""
from report.models import DataOutput
super().setUp()
for ii in range(5):
DataOutput.objects.create(
output_type='test_output',
user=self.user if ii % 2 == 0 else None,
complete=ii % 2 == 1,
)
def test_data_output_list(self):
"""Test the DataOutput API list endpoint."""
url = reverse('api-data-output-list')
# Non-staff user should only see outputs which are either enabled for all users, or created by themselves
self.user.is_staff = False
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 3)
for output in response.data:
self.assertEqual(output['user'], self.user.pk)
# Set staff access = True, so we should see all outputs
self.user.is_staff = True
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 5)
class ParameterAPITests(InvenTreeAPITestCase):
"""Tests for the Parameter API."""

View File

@ -1107,6 +1107,41 @@ class TaskListApiTests(InvenTreeAPITestCase):
for task in response.data:
self.assertEqual(task['name'], 'time.sleep')
def test_task_detail(self):
"""Test the BackgroundTaskDetail API endpoint."""
from InvenTree.tasks import offload_task
# Force run a task
result = offload_task('fake_module.test_task', force_sync=True)
self.assertFalse(result)
self.assertEqual(type(result), bool)
# Schedule a dummy task - and ensure it offloads to the worker
task_id = offload_task('fake_module.test_task', force_async=True)
self.assertIsNotNone(task_id)
self.assertEqual(type(task_id), str)
url = reverse('api-task-detail', kwargs={'task_id': task_id})
data = self.get(url, expected_code=200).data
self.assertEqual(data['task_id'], task_id)
self.assertTrue(data['exists'])
self.assertTrue(data['pending'])
self.assertFalse(data['complete'])
self.assertFalse(data['success'])
# Perform a lookup for a non-existent task
url = reverse('api-task-detail', kwargs={'task_id': 'doesnotexist'})
data = self.get(url, expected_code=404).data
self.assertEqual(data['task_id'], 'doesnotexist')
self.assertFalse(data['exists'])
self.assertFalse(data['pending'])
self.assertFalse(data['complete'])
self.assertFalse(data['success'])
class WebhookMessageTests(TestCase):
"""Tests for webhooks."""
@ -1317,12 +1352,18 @@ class NotificationTest(InvenTreeAPITestCase):
# Now, let's bulk delete all 'unread' notifications via the API,
# but only associated with the logged in user
response = self.delete(url, {'filters': {'read': False}}, expected_code=200)
read_notifications = NotificationMessage.objects.filter(read=True)
response = self.delete(
url, {'items': [ntf.pk for ntf in read_notifications]}, expected_code=200
)
# Only 7 notifications should have been deleted,
# Only 3 notifications should have been deleted,
# as the notifications associated with other users must remain untouched
self.assertEqual(NotificationMessage.objects.count(), 13)
self.assertEqual(NotificationMessage.objects.filter(user=self.user).count(), 3)
self.assertEqual(NotificationMessage.objects.count(), 17)
self.assertEqual(NotificationMessage.objects.filter(user=self.user).count(), 7)
self.assertEqual(
NotificationMessage.objects.filter(user=self.user, read=True).count(), 0
)
def test_simple(self):
"""Test that a simple notification can be created."""
@ -1534,9 +1575,14 @@ class CurrencyAPITests(InvenTreeAPITestCase):
# Updating via the external exchange may not work every time
for _idx in range(5):
self.post(
reverse('api-currency-refresh'), expected_code=200, max_query_time=30
)
try:
self.post(
reverse('api-currency-refresh'),
expected_code=200,
max_query_time=30,
)
except Exception:
continue
# There should be some new exchange rate objects now
if Rate.objects.all().exists():

View File

@ -184,6 +184,7 @@ class ManufacturerPartMixin(SerializerContextMixin):
class ManufacturerPartList(
DataExportViewMixin,
ManufacturerPartMixin,
SerializerContextMixin,
OutputOptionsMixin,

View File

@ -2,6 +2,7 @@
import os
from decimal import Decimal
from typing import TypedDict
from django.apps import apps
from django.conf import settings
@ -53,7 +54,7 @@ def rename_company_image(instance, filename):
return os.path.join(base, fn)
class CompanyReportContext(report.mixins.BaseReportContext):
class CompanyReportContext(report.mixins.BaseReportContext, TypedDict):
"""Report context for the Company model.
Attributes:
@ -243,7 +244,11 @@ class Company(
# We may have a pre-fetched primary address list
if hasattr(self, 'primary_address_list'):
addresses = self.primary_address_list
return addresses[0] if len(addresses) > 0 else None
return (
addresses[0]
if len(addresses) > 0 and isinstance(addresses, list)
else None
)
# Otherwise, query the database
return self.addresses.filter(primary=True).first()

View File

@ -194,7 +194,7 @@ class InvenTreeCustomStatusSerializerMixin:
"""Ensure the custom field is updated if the leader was changed."""
self.gather_custom_fields()
# Mirror values from leader to follower
for field in self._custom_fields_leader:
for field in self._custom_fields_leader or []:
follower_field_name = f'{field}_custom_key'
if (
field in self.initial_data
@ -205,7 +205,7 @@ class InvenTreeCustomStatusSerializerMixin:
setattr(self.instance, follower_field_name, self.initial_data[field])
# Mirror values from follower to leader
for field in self._custom_fields_follower:
for field in self._custom_fields_follower or []:
leader_field_name = field.replace('_custom_key', '')
if field in validated_data and leader_field_name not in self.initial_data:
try:
@ -276,7 +276,7 @@ class InvenTreeCustomStatusSerializerMixin:
# Inherit choices from leader
self.gather_custom_fields()
if field_name in self._custom_fields:
if self._custom_fields and field_name in self._custom_fields:
leader_field_name = field_name.replace('_custom_key', '')
leader_field = self.fields[leader_field_name]
if hasattr(leader_field, 'choices'):

View File

@ -73,6 +73,22 @@ class DataImportSessionMixin:
serializer_class = importer.serializers.DataImportSessionSerializer
permission_classes = [InvenTree.permissions.DataImporterPermission]
def get_queryset(self):
"""Return the set of DataImportSession objects that the user has permission to view."""
queryset = super().get_queryset()
try:
user = self.request.user
except AttributeError:
raise PermissionDenied('User information is not available')
# Allow staff users access to all DataImportSession objects
if user.is_staff:
return queryset
# For non-staff users, only allow access to sessions that they have created
return queryset.filter(user=user)
class DataImportSessionList(BulkDeleteMixin, DataImportSessionMixin, ListCreateAPI):
"""API endpoint for accessing a list of DataImportSession objects."""

View File

@ -24,11 +24,11 @@ class DataImportSerializerMixin:
Determine if the serializer is being used for data import,
and if so, adjust the serializer fields accordingly.
"""
importing = kwargs.pop('importing', False)
self._is_importing = kwargs.pop('importing', False)
super().__init__(*args, **kwargs)
if importing:
if self._is_importing:
# Exclude any fields which are not able to be imported
importable_field_names = list(self.get_importable_fields().keys())
field_names = list(self.fields.keys())

View File

@ -152,7 +152,7 @@ class DataImportSession(models.Model):
return supported_models().get(self.model_type, None)
def get_related_model(self, field_name: str) -> models.Model:
def get_related_model(self, field_name: str) -> Optional[models.Model]:
"""Return the related model for a given field name.
Arguments:
@ -699,7 +699,7 @@ class DataImportRow(models.Model):
if commit:
self.save()
def convert_date_field(self, value: str) -> str:
def convert_date_field(self, value: str) -> Optional[str]:
"""Convert an incoming date field to the correct format for the database."""
if value in [None, '']:
return None

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)

View File

@ -174,6 +174,36 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
# Check that there are new database records
self.assertEqual(PartCategory.objects.count(), N + 4)
def test_session_list(self):
"""Test API endpoint which details the list of import sessions."""
url = reverse('api-importer-session-list')
# Construct a dummy file
f = self.helper_file('companies.csv')
for ii in range(5):
DataImportSession.objects.create(
data_file=f,
model_type='company',
user=self.user if ii % 2 == 0 else None,
)
# Staff user should see all sessions
self.user.is_staff = True
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 5)
# Non-staff user should only see sessions which they own
self.user.is_staff = False
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 3)
for session in response.data:
self.assertEqual(session['user'], self.user.pk)
class AdminTest(ImporterMixin, AdminTestCase):
"""Tests for the admin interface integration."""

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