resolved api_version file conflicts

This commit is contained in:
Neil Beueks 2026-06-17 06:24:07 +00:00
commit 4f9a3a1dae
324 changed files with 155371 additions and 140230 deletions

View File

@ -106,7 +106,7 @@ runs:
- name: Run invoke update
if: ${{ inputs.update == 'true' }}
shell: bash
run: invoke update --skip-backup --skip-static
run: invoke update --skip-backup --skip-static --no-frontend
- name: Collect static files
if: ${{ inputs.static == 'true' }}
shell: bash

View File

@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false

View File

@ -39,7 +39,7 @@ jobs:
docker: ${{ steps.filter.outputs.docker }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -67,7 +67,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Test Docker Image
@ -129,7 +129,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Run Migration Tests
@ -153,7 +153,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Set Up Python ${{ env.python_version }}
@ -168,10 +168,10 @@ 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@ce360397dd3f832beb865e1373c09c0e9f86d70a # pin@v4.0.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # pin@v4.1.0
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # pin@v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # pin@v4.1.0
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # pin@v4.1.2
@ -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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # pin@v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # pin@v4.2.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # pin@v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # pin@v4.2.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@030e881283bb7a6894de51c315a6bfe6a94e05cf # pin@v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # pin@v6.1.0
with:
images: |
inventree/inventree
@ -210,7 +210,7 @@ jobs:
- name: Push Docker Images
id: push-docker
if: github.event_name != 'pull_request'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # pin@v1
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # pin@v1
with:
project: jczzbjkk68
context: .

308
.github/workflows/frontend.yaml vendored Normal file
View File

@ -0,0 +1,308 @@
# Playwright testing for frontend code
# Runs the following tests:
# - Playwright tests in Firefox (against compiled frontend code)
# - Playwright tests in Chromium (coverage enabled, against vite frontend code)
# - Build frontend code and upload as artifact
name: Frontend
on:
push:
branches-ignore: ["l10*"]
pull_request:
branches-ignore: ["l10*"]
env:
python_version: 3.11
node_version: 24
# The OS version must be set per job
server_start_sleep: 60
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
INVENTREE_DB_USER: inventree_user
INVENTREE_DB_PASSWORD: inventree_password
INVENTREE_DEBUG: true
INVENTREE_PLUGINS_ENABLED: false
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
permissions:
contents: read
jobs:
paths-filter:
name: Filter
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
force: ${{ steps.force.outputs.force }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
id: filter
with:
filters: |
frontend:
- 'src/frontend/**'
- name: Is CI being forced?
run: echo "force=true" >> $GITHUB_OUTPUT
id: force
if: |
contains(github.event.pull_request.labels.*.name, 'dependency') ||
contains(github.event.pull_request.labels.*.name, 'full-run')
build:
name: Build
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
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
run: |
cd src/backend/InvenTree/web/static
zip -r frontend-build.zip web/ web/.vite
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: frontend-build
path: src/backend/InvenTree/web/static/web
include-hidden-files: true
firefox:
name: Tests [Firefox ${{ matrix.shard }} / 2]
runs-on: ubuntu-24.04
timeout-minutes: 60
needs: ["build", "paths-filter"]
if: github.ref == 'refs/heads/master' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2]
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: inventree
POSTGRES_USER: inventree_user
POSTGRES_PASSWORD: inventree_password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U testuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
permissions:
contents: read # Required for actions/checkout
id-token: write # Required for GitHub OIDC
env:
VITE_COVERAGE: false
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
install: true
update: true
apt-dependency: gettext postgresql-client libpq-dev
pip-dependency: psycopg2
- name: Set up test data
run: |
invoke dev.setup-test -iv
invoke int.rebuild-thumbnails
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # pin@v5.0.5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src/frontend/yarn.lock') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd src/frontend && npx playwright install --with-deps
- name: Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: cd src/frontend && npx playwright install-deps
- name: Install Sample Plugin
run: |
pip install -U inventree-plugin-creator
create-inventree-plugin --default
cd MyCustomPlugin && pip install -e . && cd frontend && npm install && npm run translate && npm run build
- name: Run Playwright tests
id: tests
run: |
cd src/frontend
cp ./tests/fixtures/playwright_custom_logo.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_logo.png
cp ./tests/fixtures/playwright_custom_splash.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_splash.png
invoke static
env INVENTREE_CUSTOM_SPLASH="img/playwright_custom_splash.png" INVENTREE_CUSTOM_LOGO="img/playwright_custom_logo.png" PLAYWRIGHT_BASE_URL=http://localhost:8000 npx playwright test --project=firefox --shard=${{ matrix.shard }}/2
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report-firefox-${{ matrix.shard }}
path: src/frontend/playwright-report/
retention-days: 14
chromium:
name: Tests [Chromium ${{ matrix.shard }} / 4]
runs-on: ubuntu-24.04
timeout-minutes: 60
needs: ["build", "paths-filter"]
if: github.ref == 'refs/heads/master' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: inventree
POSTGRES_USER: inventree_user
POSTGRES_PASSWORD: inventree_password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U testuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
COVERAGE: true
VITE_COVERAGE: true
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
install: true
update: true
apt-dependency: gettext postgresql-client libpq-dev
pip-dependency: psycopg2
- name: Set up test data
run: |
invoke dev.setup-test -iv
invoke int.rebuild-thumbnails
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # pin@v5.0.5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src/frontend/yarn.lock') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd src/frontend && npx playwright install --with-deps
- name: Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: cd src/frontend && npx playwright install-deps
- name: Install Sample Plugin
run: |
pip install -U inventree-plugin-creator
create-inventree-plugin --default
cd MyCustomPlugin && pip install -e . && cd frontend && npm install && npm run translate && npm run build
- name: Playwright [${{ matrix.shard }} / 4]
id: tests
run: |
cd src/frontend
npx nyc playwright test --project=chromium --shard=${{ matrix.shard }}/4
- name: Playwright Report [${{ matrix.shard }} / 4]
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report-chromium-${{ matrix.shard }}
path: src/frontend/playwright-report/
if-no-files-found: error
retention-days: 7
- name: Upload Coverage Artifact [${{ matrix.shard }} / 4]
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
id: coverage-upload
if: ${{ !cancelled() && steps.tests.outcome != 'failure' }}
with:
name: coverage-${{ matrix.shard }}
path: src/frontend/.nyc_output/
if-no-files-found: error
include-hidden-files: true
retention-days: 1
# Recombine the coverage reports from the 4 shards, and upload to Codecov
coverage:
name: Merge coverage reports and upload to Codecov
runs-on: ubuntu-latest
needs: chromium
timeout-minutes: 30
if: always()
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
install: false
update: false
- name: Download Coverage Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pin@v8.0.1
with:
pattern: coverage-*
path: all-coverage/
merge-multiple: true
- name: Merge Coverage Reports
run: |
mkdir -p .nyc_output
cp all-coverage/*.json .nyc_output/ 2>/dev/null || true
npx nyc merge .nyc_output merged-coverage.json
npx nyc report \
--tempdir .nyc_output \
--reporter=lcov \
--reporter=text-summary \
--report-dir ./coverage \
- name: Upload coverage reports to Codecov
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: web
files: ./coverage/lcov.info

View File

@ -48,7 +48,7 @@ jobs:
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -76,7 +76,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
fetch-depth: 0
persist-credentials: false

View File

@ -44,7 +44,7 @@ jobs:
submit-performance: ${{ steps.runner-perf.outputs.submit-performance }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -108,7 +108,7 @@ jobs:
if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Set up Python ${{ env.python_version }}
@ -130,7 +130,7 @@ jobs:
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -152,7 +152,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Set up Python ${{ env.python_version }}
@ -190,7 +190,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -222,7 +222,7 @@ jobs:
echo "Downloaded api.yaml"
- name: Running OpenAPI Spec diff action
id: breaking_changes
uses: oasdiff/oasdiff-action/diff@6147a58e5d1249a12f42fc864ab791d571a30015 # pin@main
uses: oasdiff/oasdiff-action/diff@3530478ec30f84adedbfeb28f0d9527a290f50a9 # pin@main
with:
base: "api.yaml"
revision: "src/backend/InvenTree/schema.yml"
@ -275,7 +275,7 @@ jobs:
version: ${{ needs.schema.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
name: Checkout Code
with:
repository: inventree/schema
@ -332,7 +332,7 @@ jobs:
node_version: '>=24'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -363,7 +363,7 @@ jobs:
pip install .
if: needs.paths-filter.outputs.submit-performance == 'true'
- name: Performance Reporting
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # pin@v4.15.1
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # pin@v4.17.0
# 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:
@ -390,7 +390,7 @@ jobs:
python_version: ${{ matrix.python_version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -415,7 +415,7 @@ jobs:
path: .coverage
retention-days: 14
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -441,7 +441,7 @@ jobs:
INVENTREE_AUTO_UPDATE: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -454,7 +454,7 @@ jobs:
env:
node_version: '>=24'
- name: Performance Reporting
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # pin@v4.15.1
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # pin@v4.17.0
with:
mode: walltime
run: inv dev.test --pytest
@ -492,7 +492,7 @@ jobs:
- 6379:6379
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -541,7 +541,7 @@ jobs:
- 3306:3306
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -584,7 +584,7 @@ jobs:
- 5432:5432
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -597,7 +597,7 @@ jobs:
- name: Run Tests
run: invoke dev.test --check --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -618,7 +618,7 @@ jobs:
INVENTREE_PLUGINS_ENABLED: false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
name: Checkout Code
@ -664,133 +664,6 @@ jobs:
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
web_ui:
name: Tests - Web UI
runs-on: ubuntu-24.04
timeout-minutes: 60
needs: ["code-style", "paths-filter"]
if: needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: inventree
POSTGRES_USER: inventree_user
POSTGRES_PASSWORD: inventree_password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U testuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
INVENTREE_DB_ENGINE: postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
INVENTREE_DB_USER: inventree_user
INVENTREE_DB_PASSWORD: inventree_password
INVENTREE_DEBUG: true
INVENTREE_PLUGINS_ENABLED: false
VITE_COVERAGE_BUILD: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
install: true
update: true
apt-dependency: gettext postgresql-client libpq-dev
pip-dependency: psycopg2
- name: Set up test data
run: |
invoke dev.setup-test -iv
invoke int.rebuild-thumbnails
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # pin@v4.3.0
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src/frontend/yarn.lock') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd src/frontend && npx playwright install --with-deps
- name: Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: cd src/frontend && npx playwright install-deps
- name: Install Sample Plugin
run: |
pip install -U inventree-plugin-creator
create-inventree-plugin --default
cd MyCustomPlugin && pip install -e . && cd frontend && npm install && npm run translate && npm run build
- name: Run Playwright tests
id: tests
run: |
cd src/frontend
cp ./tests/fixtures/playwright_custom_logo.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_logo.png
cp ./tests/fixtures/playwright_custom_splash.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_splash.png
invoke static
env INVENTREE_CUSTOM_SPLASH="img/playwright_custom_splash.png" INVENTREE_CUSTOM_LOGO="img/playwright_custom_logo.png" npx nyc playwright test --project=customization
npx nyc playwright test --project=chromium --project=firefox
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report
path: src/frontend/playwright-report/
retention-days: 14
- 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@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: web
- name: Upload bundler info
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: |
cd src/frontend
yarn install
yarn run build
web_ui_build:
name: Build - Web UI
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
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
run: |
cd src/backend/InvenTree/web/static
zip -r frontend-build.zip web/ web/.vite
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: frontend-build
path: src/backend/InvenTree/web/static/web
include-hidden-files: true
zizmor:
name: Security [Zizmor]
runs-on: ubuntu-24.04
@ -801,7 +674,7 @@ jobs:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Run zizmor 🌈

View File

@ -20,7 +20,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout Code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Version Check
@ -28,7 +28,7 @@ jobs:
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
python3 .github/scripts/version_check.py
- name: Push to Stable Branch
uses: ad-m/github-push-action@4cc74773234f74829a8c21bc4d69dd4be9cfa599 # pin@v1.1.0
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # pin@v1.3.0
if: env.stable_release == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
@ -43,7 +43,7 @@ jobs:
contents: write
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -109,7 +109,7 @@ jobs:
INVENTREE_DEBUG: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -149,7 +149,7 @@ jobs:
- ubuntu:24.04
- debian:12
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
fetch-depth: 0
persist-credentials: false

View File

@ -32,7 +32,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: results.sarif

View File

@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # pin@v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # pin@v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue seems stale. Please react to show this is still important."

View File

@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup

View File

@ -9,11 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes
- [#9604](https://github.com/inventree/InvenTree/pull/9604) - refactors user API endpoint to be less ambiguous
- [#12160](https://github.com/inventree/InvenTree/pull/12160) changes the way that remote URIs are loaded into the PDF report generator. Remote URIs (e.g. `http://` and `https://`) are now blocked by default, and can be enabled via the `REPORT_FETCH_URLS` system setting. This change was made to improve the security of the report generation process, as allowing remote URL fetching can potentially expose internal network services to SSRF attacks. Additionally, file URIs (e.g. `file://`) are now always blocked, and assets must be embedded as `data:` URIs before reaching the PDF generator.
- [#12107](https://github.com/inventree/InvenTree/pull/12107) makes a breaking change to the `SalesOrderStatusGroups` enum, fixing a bug where the "shipped" status was not included in the "active" group. This change may affect any external client applications which make use of the `SalesOrderStatusGroups` enum, as the "shipped" status will now be included in the "active" group instead of the "complete" group. If you are using this enum in an external client application, you will need to update your application to account for this change.
- [#9604](https://github.com/inventree/InvenTree/pull/9604) refactors user API endpoint to be less ambiguous
- [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself
### Added
- [#12165](https://github.com/inventree/InvenTree/pull/12165) adds support for parameters against the PartCategory model
- [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns.
- [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API)
- [#12079](https://github.com/inventree/InvenTree/pull/12079) adds the ability to save filter groups for table and calendar views in the user interface. This allows users to save and reuse commonly used filter configurations, improving the usability and efficiency of the interface.
- [#12077](https://github.com/inventree/InvenTree/pull/12077) adds "tags" fields to multiple new model types and a /api/tag/ endpoint for fetching tags. Also adds the ability to filter various model types by tags.
- [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation.
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
@ -31,12 +38,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- [#12142](https://github.com/inventree/InvenTree/pull/12142) prevents users from printing reports or labels against models for which they do not have adequate permissions. This change improves the security of the system by ensuring that users cannot access or print reports or labels for models they do not have permission to view.
- [#11990](https://github.com/inventree/InvenTree/pull/11990) build output operations performed via the API now offload the work to a background task, and now return a task ID which can be used to monitor the progress of the task. This allows for better performance and responsiveness when performing build output operations, as the work is performed asynchronously in the background.
- [#11825](https://github.com/inventree/InvenTree/pull/11825) adds a new "bom" ruleset and associated permissions for BOM management, separate from the "part" ruleset which remains focused on part management. This allows for more granular control over user permissions, allowing users to have different levels of access to part management and BOM management functionality.
- [#11816](https://github.com/inventree/InvenTree/pull/11816) makes the `issued_by` field on the `Build` API read only, and instead sets the `issued_by` field to the current user when a build is created. This change was made to ensure that the `issued_by` field accurately reflects the user who created the build, and to prevent users from setting this field to an arbitrary value when creating or updating a build.
### Removed
- [#12071](https://github.com/inventree/InvenTree/pull/12071) removes the "review_needed" field on the StockItem model. Note that this field was never exposed to the API or any business logic, and is essentially a no-op field which is not used for anything. This field was removed to simplify the StockItem model and reduce confusion around its purpose.
- [#11962](https://github.com/inventree/InvenTree/pull/11962) removes the "remote_image" field from the Part API endpoint, which (previously) allowed the user to specify a remote URL for an image to be downloaded and associated with the part. This field was removed due to security concerns around downloading images from arbitrary URLs. If you were using this field in an external client application, you will need to update your application to use the new "download_image_from_url" API endpoint instead.
## 1.3.0 - 2026-04-11

View File

@ -43,8 +43,10 @@ component_management:
name: Backend Apps
paths:
- src/backend/InvenTree/build/**
- src/backend/InvenTree/common/**
- src/backend/InvenTree/company/**
- src/backend/InvenTree/data_exporter/**
- src/backend/InvenTree/generic/**
- src/backend/InvenTree/importer/**
- src/backend/InvenTree/machine/**
- src/backend/InvenTree/order/**
@ -70,7 +72,8 @@ component_management:
- component_id: web
name: Frontend
paths:
- src/frontend/**
- src/frontend/lib/**
- src/frontend/src/**
statuses:
- type: project
target: 68% # 90%

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-03-20
FROM python:3.14.5-slim-trixie@sha256:c845af9399020c7e562969a13689e929074a10fd057acd1b1fad06a2fb068e97 AS inventree_base
# Base image last bumped 2026-06-16
FROM python:3.14.6-slim-trixie@sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061 AS inventree_base
# Build arguments for this image
ARG commit_tag=""
@ -18,6 +18,7 @@ ARG commit_hash=""
ARG commit_date=""
ARG data_dir="data"
ARG NODE_VERSION=24
ENV PYTHONUNBUFFERED=1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
@ -118,15 +119,10 @@ RUN pip install --user --require-hashes -r base_requirements.txt --no-cache-dir
rm -rf /root/.cache/pip
# Install frontend build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs npm \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g n yarn --ignore-scripts && \
yarn config set network-timeout 600000 -g
RUN bash -c "n lts"
RUN cd "${INVENTREE_HOME}" && invoke int.frontend-compile --extract
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash && \
bash -c "export NVM_DIR="$HOME/.nvm" && source $HOME/.nvm/nvm.sh && \
nvm install $NODE_VERSION && corepack enable yarn && yarn config set network-timeout 600000 -g"
RUN bash -c "source $HOME/.nvm/nvm.sh && cd '${INVENTREE_HOME}' && invoke int.frontend-compile --extract"
# InvenTree production image:
# - Copies required files from local directory

View File

@ -6,9 +6,9 @@ asgiref==3.11.1 \
# via
# -c src/backend/requirements.txt
# django
django==5.2.14 \
--hash=sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2 \
--hash=sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76
django==5.2.15 \
--hash=sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970 \
--hash=sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in
@ -61,70 +61,70 @@ packaging==26.2 \
# gunicorn
# mariadb
# wheel
psycopg[binary, pool]==3.3.3 \
--hash=sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9 \
--hash=sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698
psycopg[binary, pool]==3.3.4 \
--hash=sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a \
--hash=sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc
# via -r contrib/container/requirements.in
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
psycopg-binary==3.3.4 \
--hash=sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070 \
--hash=sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c \
--hash=sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc \
--hash=sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e \
--hash=sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68 \
--hash=sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae \
--hash=sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829 \
--hash=sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097 \
--hash=sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c \
--hash=sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992 \
--hash=sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97 \
--hash=sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6 \
--hash=sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a \
--hash=sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d \
--hash=sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228 \
--hash=sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e \
--hash=sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4 \
--hash=sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41 \
--hash=sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949 \
--hash=sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9 \
--hash=sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089 \
--hash=sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf \
--hash=sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da \
--hash=sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578 \
--hash=sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014 \
--hash=sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e \
--hash=sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e \
--hash=sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31 \
--hash=sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652 \
--hash=sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b \
--hash=sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839 \
--hash=sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277 \
--hash=sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7 \
--hash=sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4 \
--hash=sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e \
--hash=sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70 \
--hash=sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf \
--hash=sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a \
--hash=sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429 \
--hash=sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8 \
--hash=sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3 \
--hash=sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13 \
--hash=sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007 \
--hash=sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc \
--hash=sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38 \
--hash=sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9 \
--hash=sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95 \
--hash=sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d \
--hash=sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16 \
--hash=sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d \
--hash=sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260 \
--hash=sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744 \
--hash=sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28 \
--hash=sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765 \
--hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7
# via psycopg
psycopg-pool==3.3.0 \
--hash=sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063 \
--hash=sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5
psycopg-pool==3.3.1 \
--hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \
--hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c
# via psycopg
pyasn1==0.6.3 \
--hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \
@ -136,8 +136,8 @@ pyasn1-modules==0.4.2 \
--hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \
--hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6
# via python-ldap
python-ldap==3.4.5 \
--hash=sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a
python-ldap==3.4.7 \
--hash=sha256:bacd9fb680d20263d8570ade1cf234d90d281149a8beb4f079dd8f33f7613dc8
# via
# -r contrib/container/requirements.in
# django-auth-ldap
@ -236,26 +236,26 @@ typing-extensions==4.15.0 \
# via
# -c src/backend/requirements.txt
# psycopg-pool
uv==0.11.7 \
--hash=sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce \
--hash=sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9 \
--hash=sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924 \
--hash=sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405 \
--hash=sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d \
--hash=sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4 \
--hash=sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d \
--hash=sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71 \
--hash=sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1 \
--hash=sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9 \
--hash=sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0 \
--hash=sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a \
--hash=sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605 \
--hash=sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d \
--hash=sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c \
--hash=sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f \
--hash=sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c \
--hash=sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9 \
--hash=sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b
uv==0.11.19 \
--hash=sha256:28b0d612a766eb25756dbaa315433b726e93affa467d29a2682cc317547952ba \
--hash=sha256:2d663bacb97e2e8412d1c26eace28c7ebbde9d6f5d7d78760fafd114d693817f \
--hash=sha256:2e0e0b8ad59ec56f1440d6e4313b64a1d8119275dcec73d19eef33c43f99428c \
--hash=sha256:301fd78309fc545c2cec2bfcc61a6bbdde876856c6d2041502737cf44085c178 \
--hash=sha256:32d7988c0dfb6f90941f201c871a4478e96e4f2a32bdb2256d62a78ee20593fc \
--hash=sha256:480fc34a8d0967af6a90b3f99a6e5687cd5c6e29528de96bec04d6e305a59363 \
--hash=sha256:50e4d4796ca1a6da359a4f723a0fea86640c381d3ff4fa759a41badd7cb52dee \
--hash=sha256:574f5dd4f31666661ea6386d3b91c5f0e8b84a8cae98ebba447c4674f2e6a4c7 \
--hash=sha256:62b0b35a51d3034ff30ecd0f381e9bbc20d5b335754f54b098da29424d551ceb \
--hash=sha256:65e932720daed1af1f720a0ff5f9b33ee5f7ad97488dcceceb85154fc1323b82 \
--hash=sha256:7222f45b5541551057bfc2e3021f113800704f665c119fdf3ea700c6c4859b21 \
--hash=sha256:731d9fab8db5d41590af64236d03f8069c8da665fd0f9493b85985f19c86cd90 \
--hash=sha256:7fdd881cd6d80782afcf8c1d446dd15a42985167fd812b763d38ba1e4a8d944d \
--hash=sha256:8f90b6687a480d154595aa619fb836a9a20d00ce37293db8099aad924f2b18f9 \
--hash=sha256:a98495b9dd67287d8c1a0786f98cb037a50f0ee6c3d648572edaa7137aabc277 \
--hash=sha256:aa6a7e8d07b33ad22f4732848ebb1d9486503973c248d6e632c06ce4339fe347 \
--hash=sha256:c729f56ffef9b945053412c839695e8a0b13758aa15b7763e95a7dd539a6f522 \
--hash=sha256:f4aa17ffd719daf37b7a6265efd3ee4922a8ddaabaf0406d2b28c7e5ce2f20ff \
--hash=sha256:f56f5bf853626a30423052d7ee00bf5cc940a08347d6ee7ede96862d084054a5
# via -r contrib/container/requirements.in
wheel==0.47.0 \
--hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced \

View File

@ -139,9 +139,9 @@ charset-normalizer==3.4.7 \
# via
# -c src/backend/requirements.txt
# requests
idna==3.16 \
--hash=sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5 \
--hash=sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d
idna==3.18 \
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
# via
# -c src/backend/requirements.txt
# requests

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,82 @@
---
title: Tags
---
## Tags
*Tags* are short, arbitrary labels that can be attached to InvenTree objects to group or classify them in flexible ways that don't require changes to the underlying data model. Unlike [parameters](./parameters.md), tags carry no typed value — they are simply names. A tag can be applied to objects of any supported model type, and tags are shared across the entire InvenTree instance.
!!! note "Shared Tag Namespace"
Tags are global: a tag named `prototype` applied to a Part and the same tag applied to a Build Order refer to the same underlying tag record. Renaming or deleting a tag affects every object to which it is attached.
### Supported Models
Tags can be attached to the following InvenTree objects:
- [Parts](../part/index.md)
- [Supplier Parts](../purchasing/supplier.md#supplier-parts)
- [Manufacturer Parts](../purchasing/manufacturer.md#manufacturer-parts)
- [Companies](./company.md)
- [Stock Items](../stock/index.md#stock-item)
- [Stock Locations](../stock/index.md#stock-location)
- [Build Orders](../manufacturing/build.md)
- [Purchase Orders](../purchasing/purchase_order.md)
- [Sales Orders](../sales/sales_order.md)
- [Return Orders](../sales/return_order.md)
- [Sales Order Shipments](../sales/sales_order.md#shipments)
## Managing Tags
### Adding and Removing Tags
Any object that supports tags will expose a *Tags* field in its detail and edit forms. Tags are entered as a comma-separated list of names and can be freely added or removed at any time. Tag names are case-insensitive — `Prototype`, `prototype`, and `PROTOTYPE` all refer to the same tag.
### Tag Names
Tag names must be unique within the InvenTree instance (case-insensitively). If you type a name that already exists under a different capitalisation, the existing tag is assigned rather than a new one created. Tag names may contain spaces, but leading and trailing whitespace is stripped automatically.
## Filtering by Tags
Tables that support tags can be filtered by one or more tag names. When multiple tags are specified, only objects that carry **all** of the specified tags are returned (AND logic).
For example, filtering a Parts table by the tags `approved` and `prototype` returns only parts tagged with both.
## API Access
### Tag Endpoints
The tag list is available at `/api/tag/`. Individual tags can be retrieved, updated, or deleted at `/api/tag/<id>/`.
The `model_type` query parameter narrows the tag list to tags currently applied to a specific model type:
```
GET /api/tag/?model_type=part
```
### Tags on Model Endpoints
For models that support tags, the `tags` field is returned in the detail endpoint response as a list of tag name strings:
```json
{
"pk": 42,
"name": "Widget",
"tags": ["approved", "prototype"]
}
```
Tags can be updated via a `PATCH` or `POST` request by supplying a JSON-encoded list of tag name strings. The full list of tags replaces the previous set — omitting a tag removes it:
```json
{
"tags": ["approved", "production"]
}
```
Tags can also be used as a filter parameter on list endpoints. Supply a comma-separated list of tag names to the `tags` query parameter:
```
GET /api/part/?tags=approved,prototype
```
This returns only parts tagged with **both** `approved` and `prototype`.

View File

@ -158,6 +158,41 @@ Select the "table filters" button to open the filter selection menu
Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view.
#### Column Filters
Many table columns expose an inline filter icon directly in the column header, providing a quick way to filter by that column without opening the full filter drawer. Columns that support filtering display a small filter icon alongside the column title. The icon is highlighted when a filter for that column is currently active, giving an at-a-glance indication of which columns have active filters.
Clicking the icon opens a compact popover anchored to the column header:
{{ image("concepts/ui_table_column_filter_popover.png", "Column Filter Popover") }}
**Single-filter columns** — for columns linked to one filter (e.g. *Active*, *Has IPN*, *Status*), selecting a value immediately applies the filter and the popover closes automatically.
**Range columns** — for columns that represent a range concept (e.g. *Start Date*, *Target Date*, *Creation Date*), the popover stays open and presents multiple controls — for example *before* and *after* date pickers — so both bounds can be set in a single interaction.
Once a filter is active, the popover shows a badge with the current value and a remove button (red ×) instead of the value picker. Clicking the × clears only that column's filter.
!!! info "Column filters and the filter drawer share the same state"
Filters applied via a column popover appear immediately in the filter drawer's active-filter list, and filters added through the drawer are reflected in the column icons. Clearing all filters from the drawer also removes any filters set via column popovers.
#### Saved Filter Groups
Frequently used combinations of filters can be saved as a named *filter group*, allowing them to be quickly recalled later without having to re-add each filter individually.
The **Saved Filter Groups** panel is displayed at the bottom of the filter drawer. When one or more filters are active, a **Save current filters** button is available. Clicking it opens an inline name input — enter a name and press Enter (or click the confirm icon) to save the group. Press Escape or click the cancel icon to discard.
{{ image("concepts/ui_table_filter_group.png", "Filter Groups") }}
Previously saved filter groups are listed in the panel. Each entry shows the group name alongside two actions:
- **Load** (green reload icon): Replaces the current active filters with the filters stored in that group. The table immediately re-fetches data using the restored filters.
- **Delete** (red × icon): Permanently removes the saved filter group.
Saved filter groups are stored in the browser's local storage and are specific to each table or calendar view, so groups saved for one view are not available in another. They persist across local browser sessions until explicitly deleted. Filter groups are not shared to other devices.
!!! info "Loading a filter group replaces active filters"
Loading a saved filter group replaces all currently active filters with those stored in the group. Any unsaved active filters will be overwritten.
### Data Sorting
Some table columns support data sorting, allowing the dataset to be sorted in ascending or descending order based on the values in that column. To sort a column, click on the column header. Clicking the column header again will toggle the sort order between ascending and descending. The current sort order is indicated by an arrow icon in the column header.
@ -190,7 +225,7 @@ For tables which reference other objects within the system, clicking on a row wi
## Calendar Views
Some [table views](#table-views) can be switched to a calendar view, which provides a visual representation of data based on date fields. The calendar view allows users to easily see and interact with data that is organized by date, such as scheduled tasks, events, or deadlines.
Some [table views](#table-views) associated with various order types can be switched to a calendar view, which provides a visual representation of data based on date fields. The calendar view allows users to easily see and interact with data that is organized by date, such as scheduled tasks, events, or deadlines.
To switch to the "calendar view" (for a table which supports it), click on the "calendar view" button located above and to the right of the table view:
@ -200,6 +235,10 @@ This will display the data in a calendar format:
{{ image("concepts/ui_calendar_view.png", "Calendar View") }}
### Calendar Horizon
The calendar view provides a configurable "horizon" setting, which allows users to adjust the number of months displayed in the calendar view.
## Parametric Views
Some [table views](#table-views) can be switched to a parametric view, which provides a visual representation of data based on specific parameters or attributes. The parametric view allows users to easily see and interact with data that is organized by certain characteristics, such as categories, types, or other relevant attributes.

View File

@ -60,15 +60,17 @@ invoke dev.setup-dev
InvenTree roughly follow the [GitLab flow](https://about.gitlab.com/topics/version-control/what-are-gitlab-flow-best-practices/) branching style, to allow simple management of multiple tagged releases, short-lived branches, and development on the main branch.
There are nominally 5 active branches:
- `master` - The main development branch
- `stable` - The latest stable release
- `next-breaking` - The next breaking release (e.g. 2.0, 3.0) with all deprecated features removed
- `l10n` - Translation branch: Source to Crowdin
- `l10_crowdin` - Translation branch: Source from Crowdin
- `y.y.x` - Release branch for the currently supported version (e.g. `0.5.x`)
All other branches are removed periodically by maintainers or core team members. This includes old release branches.
Do not use them as base for feature development or forks as patches from them might not be accepted without rebasing.
| Branch | Description |
| --- | --- |
| `master` | The main development branch |
| `stable` | The latest stable release |
| `next-breaking` | The next breaking release (e.g. 2.0, 3.0) with all deprecated features removed |
| `l10n` | Translation branch: Source to Crowdin |
| `l10_crowdin` | Translation branch: Source from Crowdin |
| `y.y.x` | Release branch for the currently supported version (e.g. `0.5.x`) |
All other branches are removed periodically by maintainers or core team members. This includes old release branches. Do not use them as base for feature development or forks as patches from them might not be accepted without rebasing.
### Version Numbering

View File

@ -35,7 +35,7 @@ If the installed version of invoke is too old, users may see error messages duri
Make sure you are running a stable or production release of InvenTree. The frontend panel is not included in development releases.
More Information: [Error Codes - INVE-E1](./settings/error_codes.md#inve-e1)
### No module named <xxx>
### No module named xyz
During the install or update process, you may be presented with an error like:

View File

@ -214,10 +214,10 @@ The frontend code for your plugin is located in the `frontend/src` directory. Yo
Refer to the `./frontend/src/Panel.tsx` file as a starting point. This is where the custom panel for the part detail page is implemented. You can modify this file to change the content and behavior of the panel.
While the `npm dev` server is running, any changes you make to the frontend code will be automatically reloaded allowing for rapid development and testing of your plugin's frontend features. This avoids the need to rebuild the frontend code every time you make a change.
While the `npm dev` server is running, any changes to the frontend are reflected in the browser using React Fast Refresh, allowing for rapid development without rebuilding the frontend with every change.
!!! info "Page Reload"
Due to the way the InvenTree frontend is structured, you will need to manually refresh the page in your browser to see changes to the frontend code. The development server will automatically reload the frontend code, but the InvenTree server needs to be aware of the changes.
All exports in plugin modules that export React components must start with a capital letter. Otherwise, React Fast Refresh will fall back to a full page reload instead of performing a component-level update. Additionally, any render functions referenced from Python must also be capitalized.
## Build Plugin

View File

@ -119,7 +119,7 @@ function AttachmentCarouselPanel({context}: {context: InvenTreePluginContext;})
}
// This is the function which is called by InvenTree to render the actual panel component
export function renderAttachmentCarouselPanel(context: InvenTreePluginContext) {
export function RenderAttachmentCarouselPanel(context: InvenTreePluginContext) {
checkPluginVersion(context);
return <AttachmentCarouselPanel context={context} />;
}
@ -300,7 +300,7 @@ Back to the walkthrough, open `core.py` in the `attachment_carousel` folder and
'title': 'Attachment Carousel',
'description': 'Custom panel description',
'icon': 'ti:carousel-horizontal:outline',
'source': self.plugin_static_file('Panel.js:renderAttachmentCarouselPanel'),
'source': self.plugin_static_file('Panel.js:RenderAttachmentCarouselPanel'),
'context': {
# Provide additional context data to the panel
'settings': self.get_settings_dict(),
@ -385,7 +385,7 @@ panels.append({
'description': 'Custom panel description',
- 'icon': 'ti:mood-smile:outline',
+ 'icon': 'ti:carousel-horizontal:outline',
'source': self.plugin_static_file('Panel.js:renderAttachmentCarouselPanel'),
'source': self.plugin_static_file('Panel.js:RenderAttachmentCarouselPanel'),
'context': {
# Provide additional context data to the panel
'settings': self.get_settings_dict(),
@ -519,7 +519,7 @@ panels.append({
'title': 'Attachment Carousel',
'description': 'Custom panel description',
'icon': 'ti:carousel-horizontal:outline',
'source': self.plugin_static_file('Panel.js:renderAttachmentCarouselPanel'),
'source': self.plugin_static_file('Panel.js:RenderAttachmentCarouselPanel'),
'context': {
# Provide additional context data to the panel
'settings': self.get_settings_dict(),

View File

@ -256,7 +256,6 @@ Each part object has access to a lot of context variables about the part. The fo
| expiry_date | Expiry date of the [StockItem](#stock-item) (optional) |
| stocktake_date | Date of last stocktake for this item |
| stocktake_user | User that performed the most recent stocktake |
| review_needed | Flag if [StockItem](#stock-item) needs review |
| delete_on_deplete | If True, [StockItem](#stock-item) will be deleted when the stock level gets to zero |
| status | Status of this [StockItem](#stock-item) (ref: InvenTree.status_codes.StockStatus) |
| status_label | Textual representation of the status e.g. "OK" |
@ -342,7 +341,6 @@ Each part object has access to a lot of context variables about the part. The fo
| note | Longer form note field |
| base_cost | Base charge added to order independent of quantity e.g. "Reeling Fee" |
| multiple | Multiple that the part is provided in |
| lead_time | Supplier lead time |
| packaging | packaging that the part is supplied in, e.g. "Reel" |
| pretty_name | The IPN, supplier name, supplier SKU and (if not null) manufacturer string joined by `|`. Ex. `P00037 | Company | 000021` |
| unit_pricing | The price for one unit. |

View File

@ -230,3 +230,35 @@ And the snippet file `stock_row.html` may be written as follows:
</tr>
{% endraw %}
```
## Security
Report templates are powerful by design — they have access to the full Django template language and to model data across the InvenTree database. For this reason, **template upload is restricted to staff users only**.
### URL Fetching
When WeasyPrint renders a template to PDF it can make outbound requests to load images, stylesheets, and fonts referenced in the HTML. InvenTree restricts this through a custom URL fetcher with the following rules:
| URL Type | Behavior |
|---|---|
| `data:` URIs | Always permitted — self-contained, no network access |
| `file://` | Always blocked — assets and images must be inlined as `data:` URIs before reaching WeasyPrint |
| `http` / `https` | Disabled by default, but can be blocked - see *Remote URL Fetching* below |
| Any other scheme | Always blocked |
HTTP redirects are also disabled: a URL that passes validation cannot redirect to an internal address.
### Remote URL Fetching
The **Report URL Fetching** system setting (`REPORT_FETCH_URLS`) controls whether `http://` and `https://` URLs in templates are permitted. It defaults to **disabled**.
When enabled, URLs are still validated against private, loopback, link-local, and reserved IP ranges before the request is made, preventing templates from being used as a vector for [Server-Side Request Forgery (SSRF)](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) attacks against internal network services.
!!! warning "Enable with care"
Enabling remote URL fetching allows report templates to trigger outbound HTTP requests from the InvenTree server. Only enable this if your templates genuinely require it, and ensure that templates are reviewed before deployment.
### Asset Files
Asset files uploaded through the admin interface are embedded directly into the rendered PDF as base64 `data:` URIs — they are read via the Django storage API and never loaded through WeasyPrint's URL fetcher. This means assets work correctly regardless of whether remote URL fetching is enabled, and also work with remote storage backends such as S3.
There are various [helper functions](./helpers.md#report-assets) available to assist with embedding assets into templates.

View File

@ -1,5 +1,5 @@
---
title: Report and LabelGeneration
title: Report and Label Generation
---
## Custom Reports

View File

@ -33,6 +33,7 @@ Configuration of basic server settings:
{{ globalsetting("DISPLAY_FULL_NAMES") }}
{{ globalsetting("DISPLAY_PROFILE_INFO") }}
{{ globalsetting("WEEK_STARTS_ON") }}
{{ globalsetting("CALENDAR_HORIZON_MONTHS") }}
{{ globalsetting("INVENTREE_UPLOAD_MAX_SIZE") }}
{{ globalsetting("INVENTREE_STRICT_URLS") }}
@ -143,6 +144,7 @@ Configuration of report generation:
{{ globalsetting("REPORT_ENABLE") }}
{{ globalsetting("REPORT_DEFAULT_PAGE_SIZE") }}
{{ globalsetting("REPORT_DEBUG_MODE") }}
{{ globalsetting("REPORT_FETCH_URLS") }}
{{ globalsetting("REPORT_LOG_ERRORS") }}
### Label Printing

View File

@ -555,6 +555,15 @@ To override global settings, provide a "dictionary" of settings overrides in the
{{ configtable() }}
{{ configsetting("INVENTREE_GLOBAL_SETTINGS") }} JSON object containing global settings overrides |
## Override Plugin Settings
If you have plugins installed which require configuration, you can provide plugin settings overrides in the configuration file, or via an environment variable.
{{ configtable() }}
{{ configsetting("INVENTREE_PLUGIN_SETTINGS") }} JSON object containing plugin settings overrides |
Note that plugin settings overrides require knowledge of the plugin "slug" and the particular settings which are being overridden. You should refer to the plugin documentation for more information on available settings. If plugin overrides are specified, but no matching plugin is found, the overrides will be ignored.
## Other Settings
Other available settings, not categorized above, are detailed in the table below:

View File

@ -102,6 +102,7 @@ nav:
- Project Codes: concepts/project_codes.md
- Attachments: concepts/attachments.md
- Parameters: concepts/parameters.md
- Tags: concepts/tags.md
- Barcodes:
- Barcode Support: barcodes/index.md
- Internal Barcodes: barcodes/internal.md

View File

@ -1,7 +1,7 @@
mkdocs==1.6.1
mkdocs-macros-plugin>=0.7,<2.0
mkdocs-material>=9.0,<10.0
mkdocs-git-revision-date-localized-plugin>=1.5.2,<2.0
mkdocs-git-revision-date-localized-plugin>=1.5.3,<2.0
mkdocs-redirects
mkdocs-simple-hooks>=0.1,<1.0
mkdocs-include-markdown-plugin

View File

@ -227,9 +227,9 @@ httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via neoteroi-mkdocs
idna==3.16 \
--hash=sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5 \
--hash=sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d
idna==3.18 \
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
# via
# -c src/backend/requirements.txt
# anyio
@ -398,9 +398,9 @@ mkdocs-get-deps==0.2.2 \
--hash=sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1 \
--hash=sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650
# via mkdocs
mkdocs-git-revision-date-localized-plugin==1.5.2 \
--hash=sha256:0b3d65abdb8b82591d724c1d5ece6af7bb3cd305bdd47e2fadd430886a9a2513 \
--hash=sha256:4f3175e039bc4fe0055cac97d295ce8d0d233a13d65986d42fd5324e4985acc2
mkdocs-git-revision-date-localized-plugin==1.5.3 \
--hash=sha256:873444b54cab4d47c69bd6e85da05ef5fbe81fee27e64508114c46a0e4f81e37 \
--hash=sha256:cd96e432de6a7e59b31c7041574b22f84179c8636835419ff458877ecfaaaf05
# via -r docs/requirements.in
mkdocs-include-markdown-plugin==7.3.0 \
--hash=sha256:2800126746452e31c2e321bbd43c8190b356e0de353e20cbc16a34a3c3d6796c \
@ -463,9 +463,9 @@ pathspec==1.0.4 \
# mkdocs
# mkdocs-macros-plugin
# properdocs
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
platformdirs==4.10.0 \
--hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \
--hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a
# via
# -c src/backend/requirements.txt
# mkdocs-get-deps

View File

@ -15,7 +15,7 @@ from django.views.generic.base import RedirectView
import structlog
from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import serializers, viewsets
from rest_framework import permissions, serializers, viewsets
from rest_framework.generics import GenericAPIView
from rest_framework.request import clone_request
from rest_framework.response import Response
@ -356,7 +356,10 @@ class InfoView(APIView):
class NotFoundView(APIView):
"""Simple JSON view when accessing an invalid API view."""
permission_classes = [InvenTree.permissions.AllowAnyOrReadScope]
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.AllowAnyOrReadScope,
]
def not_found(self, request):
"""Return a 404 error."""

View File

@ -1,17 +1,43 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 500
INVENTREE_API_VERSION = 508
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v500 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12022
v508 -> 2026-06-17 : https://github.com/inventree/InvenTree/pull/12022
- Adds optional "merge" field to each item in the Stock Transfer API endpoint
- When merge is enabled, transferred stock is combined into compatible existing stock at the destination
- Stock merge tracking entries now include an "added" delta field.
v507 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/12180
- Adds "lookup_field" parameter to the DataImportSessionSerializer, which allows for more flexible lookup of related objects during data import operations
v506 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12168
- Reduce permissions scope for a number of API endpoints, to improve security and ensure that users only have access to the data they need
v505 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12165
- Allow parameters to be specified against the PartCategory model
v504 -> 2026-06-13 : https://github.com/inventree/InvenTree/pull/12139
- Adjustments to the SelectionList and SelectionListEntry API endpoints to support more efficient queries and data retrieval
v503 -> 2026-06-11 : https://github.com/inventree/InvenTree/pull/12155
- Adds additional filtering and sorting options to the LabelTemplate API endpoint
- Adds additional filtering and sorting options to the ReportTemplate API endpoint
v502 -> 2026-06-10 : https://github.com/inventree/InvenTree/pull/12142
- Prevents users from printing reports or labels against models for which they do not have adequate permissions. This change improves the security of the system by ensuring that users cannot access or print reports or labels for models they do not have permission to view.
v501 -> 2026-06-05 : https://github.com/inventree/InvenTree/pull/12093
- Adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API)
v500 -> 2026-06-03 : https://github.com/inventree/InvenTree/pull/12077
- Adds "tags" fields to multiple new model types
- Adds /api/tag/ endpoint for fetching tags
- Enable filtering various model types by tags
v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057
- Fixes search field issues on the BarcodeScanHistory API endpoint

View File

@ -114,6 +114,9 @@ def reload_unit_registry():
reg.define('@alias degF = Fahrenheit')
reg.define('@alias degK = Kelvin')
# Override R as ohm (pint defines R as an SI prefix by default)
reg.define('R = ohm')
# Define some "standard" additional units
reg.define('piece = 1')
reg.define('each = 1 = ea')

View File

@ -24,6 +24,7 @@ from error_report.models import Error
from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey
from stdimage.models import StdImageField
from taggit.managers import TaggableManager
import common.settings
import InvenTree.exceptions
@ -1250,6 +1251,25 @@ class InvenTreeNotesMixin(models.Model):
)
class InvenTreeTagsMixin(models.Model):
"""A mixin class for adding tag functionality to a model class.
The following fields are added to any model which implements this mixin:
- tags : A text field for storing comma-separated tags
"""
class Meta:
"""Metaclass options for this mixin.
Note: abstract must be true, as this is only a mixin, not a separate table
"""
abstract = True
tags = TaggableManager(blank=True)
class InvenTreeBarcodeMixin(models.Model):
"""A mixin class for adding barcode functionality to a model class.

View File

@ -415,7 +415,6 @@ class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission):
"""Check that the requesting user is 'admin'."""
try:
user = request.user
if request.method in permissions.SAFE_METHODS:
return True
# Any other methods require staff access permissions

View File

@ -699,10 +699,6 @@ class InvenTreeTaggitSerializer(TaggitSerializer):
return self._save_tags(tag_object, to_be_tagged)
class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSerializer):
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path.

View File

@ -1100,6 +1100,21 @@ if len(GLOBAL_SETTINGS_OVERRIDES) > 0:
# Set the global setting
logger.debug('- Override value for %s = ********', key)
# Plugin settings overrides
# If provided, these values will override any plugin settings (and prevent them from being changed)
# Specified as a nested dict: {plugin_slug: {SETTING_KEY: value}}
PLUGIN_SETTING_OVERRIDES = get_setting(
'INVENTREE_PLUGIN_SETTINGS', 'plugin_settings', typecast=dict
)
if len(PLUGIN_SETTING_OVERRIDES) > 0:
logger.info(
'INVE-I1: Plugin settings overrides: %s', list(PLUGIN_SETTING_OVERRIDES.keys())
)
for slug, overrides in PLUGIN_SETTING_OVERRIDES.items():
for key in overrides:
logger.debug('- Override value for %s.%s = ********', slug, key)
# User interface customization values
CUSTOM_LOGO = get_setting('INVENTREE_CUSTOM_LOGO', 'customize.logo', typecast=str)
@ -1169,3 +1184,6 @@ if 'dbbackup' not in STORAGES:
if _media:
MEDIA_URL = _media
PRESIGNED_URL_EXPIRATION = 600
# Taggit settings
TAGGIT_CASE_INSENSITIVE = True

View File

@ -612,6 +612,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
response = self.get(
url, headers={'Authorization': f'Token {token}'}, max_query_count=20
)
self.assertIsNotNone(data.get('active_plugins'))
self.assertGreater(len(response.json()['database']), 4)
data = response.json()

View File

@ -293,6 +293,16 @@ class ConversionTest(TestCase):
)
self.assertAlmostEqual(output, expected, 12)
# Test that 'R' is interpreted as ohms
# Ref: https://github.com/inventree/InvenTree/issues/12063
r_tests = [('8R6', 8.6), ('10R', 10), ('4R7', 4.7), ('100R', 100)]
for val, expected in r_tests:
output = InvenTree.conversion.convert_physical_value(
val, 'ohm', strip_units=True
)
self.assertAlmostEqual(output, expected, 6)
def test_scientific_notation(self):
"""Test that scientific notation is handled correctly."""
tests = [

View File

@ -55,6 +55,10 @@ try:
except (KeyError, IndexError):
logger.warning('INVE-W1: Current branch could not be detected.')
main_branch = None
if main_repo is not None:
main_repo.close()
main_repo = None
except ImportError:
logger.warning(
'INVE-W2: Dulwich module not found, git information will not be available.'

View File

@ -3,7 +3,8 @@
from __future__ import annotations
from django.contrib.auth.models import User
from django.db.models import F, Q
from django.db.models import F, OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce
from django.urls import include, path
from django.utils.translation import gettext_lazy as _
@ -16,6 +17,7 @@ from rest_framework.response import Response
import build.models as build_models
import build.serializers
import common.filters
import common.models
import common.serializers
import part.models as part_models
@ -307,6 +309,8 @@ class BuildFilter(FilterSet):
return queryset
tags = common.filters.TagsFilter()
class BuildMixin:
"""Mixin class for Build API endpoints."""
@ -517,8 +521,22 @@ class BuildLineFilter(FilterSet):
- The quantity available for each BuildLine (including variants and substitutes)
- The quantity allocated for each BuildLine
"""
flt = Q(
quantity__lte=F('allocated')
allocated_subquery = (
BuildItem.objects
.filter(build_line=OuterRef('pk'))
.values('build_line')
.annotate(total=Sum('quantity'))
.values('total')
)
queryset = queryset.alias(
allocated_quantity=Coalesce(Subquery(allocated_subquery), 0)
)
# A query filter construct to determine the total quantity available for this BuildLine,
# taking into account any stock which is already allocated or consumed
available = (
F('allocated_quantity')
+ F('consumed')
+ F('available_stock')
+ F('available_substitute_stock')
@ -526,8 +544,9 @@ class BuildLineFilter(FilterSet):
)
if str2bool(value):
return queryset.filter(flt)
return queryset.exclude(flt)
return queryset.filter(quantity__lte=available)
return queryset.filter(quantity__gt=available)
on_order = rest_filters.BooleanFilter(label=_('On Order'), method='filter_on_order')

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.14 on 2026-06-03 10:07
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("build", "0058_buildline_consumed"),
(
"taggit",
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
),
]
operations = [
migrations.AddField(
model_name="build",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -79,6 +79,7 @@ class Build(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.ReferenceIndexingMixin,
StateTransitionMixin,
@ -964,7 +965,7 @@ class Build(
# Remove the build output from the database
# This is a special case where serialized stock can be deleted,
# independedent of the global setting which normally prevents deletion of serialized stock items
# independent of the global setting which normally prevents deletion of serialized stock items
output.delete(ignore_serial_check=True)
@transaction.atomic

View File

@ -36,6 +36,7 @@ from InvenTree.serializers import (
FilterableSerializerMixin,
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
)
@ -57,6 +58,7 @@ class BuildSerializer(
CustomStatusSerializerMixin,
FilterableSerializerMixin,
NotesFieldMixin,
InvenTreeTaggitSerializer,
DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin,
InvenTreeModelSerializer,
@ -103,6 +105,7 @@ class BuildSerializer(
'parameters',
'priority',
'level',
'tags',
]
read_only_fields = [
'completed',
@ -127,6 +130,8 @@ class BuildSerializer(
parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter()
part_name = serializers.CharField(
source='part.name', read_only=True, label=_('Part Name')
)

View File

@ -11,7 +11,7 @@ from build.models import Build, BuildItem, BuildLine
from build.status_codes import BuildStatus
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import BomItem, Part
from part.models import BomItem, BomItemSubstitute, Part
from stock.models import StockItem, StockLocation, StockSortOrder
from stock.status_codes import StockStatus
@ -1625,6 +1625,175 @@ class BuildLineTests(BuildAPITest):
self.assertEqual(n_t + n_f, BuildLine.objects.count())
def test_filter_available_allocated_consumed_mixed(self):
"""Filter BuildLine objects with mixed allocated / consumed / stock states."""
assembly = Part.objects.create(
name='Available Filter Assembly',
description='Assembly for advanced available filter tests',
assembly=True,
)
components = [
Part.objects.create(
name=f'Available Filter Component {idx}',
description=f'Component {idx}',
component=True,
)
for idx in range(4)
]
# The assembly uses 10x of each component
for component in components:
BomItem.objects.create(part=assembly, sub_part=component, quantity=10)
# Create a new Build, requiring 100x of each component
build = Build.objects.create(
part=assembly,
reference='BO-9999',
quantity=10,
title='Available Filter Mixed',
)
lines = list(build.build_lines.order_by('pk'))
self.assertEqual(len(lines), 4)
# Build line quantity is 100 for each line (10 * build quantity 10)
for line in lines:
self.assertEqual(line.quantity, 100)
# Stock allocation baseline for each component
# Note: Quantity values will be updated later
stock_items = [
StockItem.objects.create(part=component, quantity=1)
for component in components
]
# Line 0: AVAILABLE = True
# allocated = 30
# consumed = 0
# available = 70
BuildItem.objects.create(
build_line=lines[0], stock_item=stock_items[0], quantity=30
)
stock_items[0].quantity = 30 + 70
stock_items[0].save()
# Line 1: allocated 20 + consumed 30 + available stock 50 => available (100)
BuildItem.objects.create(
build_line=lines[1], stock_item=stock_items[1], quantity=20
)
lines[1].consumed = 30
lines[1].save()
stock_items[1].quantity = 20 + 50
stock_items[1].save()
# Line 2: allocated 0 + consumed 10 + available stock 50 => not available (60)
lines[2].consumed = 10
lines[2].save()
stock_items[2].quantity = 50
stock_items[2].save()
# Line 3: allocated 40 + consumed 0 + available stock 20 => not available (60)
BuildItem.objects.create(
build_line=lines[3], stock_item=stock_items[3], quantity=40
)
stock_items[3].quantity = 40 + 20
stock_items[3].save()
url = reverse('api-build-line-list')
response_true = self.get(url, {'build': build.pk, 'available': True})
response_false = self.get(url, {'build': build.pk, 'available': False})
true_ids = {item['pk'] for item in response_true.data}
false_ids = {item['pk'] for item in response_false.data}
self.assertSetEqual(true_ids, {lines[0].pk, lines[1].pk})
self.assertSetEqual(false_ids, {lines[2].pk, lines[3].pk})
self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines})
def test_filter_available_substitute_and_variant_stock(self):
"""Filter BuildLine objects where availability comes from substitute or variant stock."""
assembly = Part.objects.create(
name='Available Filter Sub/Var Assembly',
description='Assembly for substitute and variant availability tests',
assembly=True,
)
# Substitute path: line should pass via substitute stock
sub_master_ok = Part.objects.create(name='Sub Master OK', component=True)
sub_alt_ok = Part.objects.create(name='Sub Alt OK', component=True)
# Substitute path: line should fail (insufficient substitute stock)
sub_master_low = Part.objects.create(name='Sub Master Low', component=True)
sub_alt_low = Part.objects.create(name='Sub Alt Low', component=True)
# Variant path: line should pass via variant stock
var_parent_ok = Part.objects.create(
name='Variant Parent OK', component=True, is_template=True
)
var_child_ok = Part.objects.create(
name='Variant Child OK', component=True, variant_of=var_parent_ok
)
# Variant path: line should fail (insufficient variant stock)
var_parent_low = Part.objects.create(
name='Variant Parent Low', component=True, is_template=True
)
var_child_low = Part.objects.create(
name='Variant Child Low', component=True, variant_of=var_parent_low
)
bom_sub_ok = BomItem.objects.create(
part=assembly, sub_part=sub_master_ok, quantity=10, allow_variants=False
)
bom_sub_low = BomItem.objects.create(
part=assembly, sub_part=sub_master_low, quantity=10, allow_variants=False
)
bom_var_ok = BomItem.objects.create(
part=assembly, sub_part=var_parent_ok, quantity=10, allow_variants=True
)
bom_var_low = BomItem.objects.create(
part=assembly, sub_part=var_parent_low, quantity=10, allow_variants=True
)
BomItemSubstitute.objects.create(bom_item=bom_sub_ok, part=sub_alt_ok)
BomItemSubstitute.objects.create(bom_item=bom_sub_low, part=sub_alt_low)
# Build quantity 10 => each line requires 100 units
build = Build.objects.create(
part=assembly, reference='BO-0987', quantity=10, title='Available Sub/Var'
)
lines = list(build.build_lines.order_by('pk'))
self.assertEqual(len(lines), 4)
# Keep master parts at zero stock so only substitute/variant paths contribute
StockItem.objects.create(part=sub_alt_ok, quantity=100)
StockItem.objects.create(part=sub_alt_low, quantity=40)
StockItem.objects.create(part=var_child_ok, quantity=100)
StockItem.objects.create(part=var_child_low, quantity=40)
url = reverse('api-build-line-list')
response_true = self.get(url, {'build': build.pk, 'available': True})
response_false = self.get(url, {'build': build.pk, 'available': False})
pk_by_bom = {line.bom_item_id: line.pk for line in lines}
expected_true = {pk_by_bom[bom_sub_ok.pk], pk_by_bom[bom_var_ok.pk]}
expected_false = {pk_by_bom[bom_sub_low.pk], pk_by_bom[bom_var_low.pk]}
true_ids = {item['pk'] for item in response_true.data}
false_ids = {item['pk'] for item in response_false.data}
self.assertSetEqual(true_ids, expected_true)
self.assertSetEqual(false_ids, expected_false)
self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines})
def test_output_options(self):
"""Test output options for the BuildLine endpoint."""
self.run_output_test(

View File

@ -36,6 +36,7 @@ from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from sql_util.utils import SubqueryCount
from taggit.models import Tag
import common.filters
import common.models
@ -504,6 +505,48 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [IsStaffOrReadOnlyScope]
class TagFilter(FilterSet):
"""Custom filters for the TagList API endpoint."""
class Meta:
"""Metaclass options for the filterset."""
model = Tag
fields = []
model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type')
def filter_model_type(self, queryset, name, value):
"""Filter to tags which have been applied to the given model type."""
ct = common.filters.determine_content_type(value)
if ct is None:
raise ValidationError({'model_type': f'Invalid model type: {value}'})
return queryset.filter(taggit_taggeditem_items__content_type=ct).distinct()
class TagMixin:
"""Mixin class for Tag views."""
serializer_class = common.serializers.TagSerializer
queryset = Tag.objects.all()
permission_classes = [IsStaffOrReadOnlyScope]
class TagList(TagMixin, ListCreateAPI):
"""List view for all tags."""
filterset_class = TagFilter
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['name']
search_fields = ['name']
class TagDetail(TagMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a particular tag."""
class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet):
"""List view for custom units."""
@ -745,6 +788,8 @@ class AttachmentFilter(FilterSet):
return queryset.exclude(attachment=None).exclude(attachment='')
return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct()
tags = common.filters.TagsFilter()
class AttachmentMixin:
"""Mixin class for Attachment views."""
@ -1162,29 +1207,25 @@ class IconList(ListAPI):
return list(get_icon_packs().values())
class SelectionListList(ListCreateAPI):
class SelectionListMixin(OutputOptionsMixin):
"""Mixin for SelectionList views."""
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self):
"""Override the queryset method to include entry count."""
return self.serializer_class.annotate_queryset(super().get_queryset())
class SelectionListList(SelectionListMixin, ListCreateAPI):
"""List view for SelectionList objects."""
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self):
"""Override the queryset method to include entry count."""
return self.serializer_class.annotate_queryset(super().get_queryset())
class SelectionListDetail(RetrieveUpdateDestroyAPI):
class SelectionListDetail(SelectionListMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a SelectionList object."""
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self):
"""Override the queryset method to include entry count."""
return self.serializer_class.annotate_queryset(super().get_queryset())
class EntryMixin:
"""Mixin for SelectionEntry views."""
@ -1201,6 +1242,12 @@ class EntryMixin:
queryset = queryset.prefetch_related('list')
return queryset
def perform_destroy(self, instance):
"""Prevent deletion of entries belonging to a locked selection list."""
if instance.list.locked:
raise PermissionDenied(_('Selection list is locked'))
super().perform_destroy(instance)
class SelectionEntryList(EntryMixin, ListCreateAPI):
"""List view for SelectionEntry objects."""
@ -1358,6 +1405,7 @@ class ObservabilityEndSerializer(serializers.Serializer):
class ObservabilityEnd(CreateAPI):
"""Endpoint for observability tools."""
# Note: This endpoint can be called anonymously, as it needs to function before the user is authenticated (e.g. during login)
permission_classes = [AllowAnyOrReadScope]
serializer_class = ObservabilityEndSerializer
@ -1554,6 +1602,14 @@ common_api_urls = [
path('', ProjectCodeList.as_view(), name='api-project-code-list'),
]),
),
# Tags (via django-taggit)
path(
'tag/',
include([
path('<int:pk>/', TagDetail.as_view(), name='api-tag-detail'),
path('', TagList.as_view(), name='api-tag-list'),
]),
),
# Flags
path(
'flags/',

View File

@ -19,6 +19,7 @@ from django.db.models import (
from django.db.models.query import QuerySet
from django.utils.translation import gettext_lazy as _
import django_filters.rest_framework.filters as rest_filters
from rest_framework import serializers
from taggit.serializers import TagListSerializerField
@ -93,6 +94,36 @@ def filter_content_type(
return queryset.filter(q)
class TagsFilter(rest_filters.CharFilter):
"""Filter which accepts a comma-separated list of tag names and returns only objects that have ALL of the specified tags.
Example usage in a FilterSet:
tags = TagsFilter(label=_('Tags'))
Example query:
?tags=apple,banana returns only items tagged with both 'apple' AND 'banana'
"""
def __init__(self, *args, **kwargs):
"""Initialize the filter."""
if 'label' not in kwargs:
kwargs['label'] = _('Tags')
super().__init__(*args, **kwargs)
def filter(self, qs, value):
"""Filter queryset to items matching all provided tag names."""
if not value:
return qs
tag_names = [t.strip() for t in value.split(',') if t.strip()]
for tag in tag_names:
qs = qs.filter(tags__name__iexact=tag)
return qs.distinct()
"""A list of valid operators for filtering part parameters."""
PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains']

View File

@ -0,0 +1,29 @@
"""Migration to change NotificationMessage object_id fields from PositiveIntegerField to CharField.
This allows notifications to reference models that use non-integer primary keys,
such as UUIDField (e.g. MachineConfig), without a database overflow error.
See: https://github.com/inventree/InvenTree/issues/12131
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0043_auto_20260518_1206'),
]
operations = [
migrations.AlterField(
model_name='notificationmessage',
name='target_object_id',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='notificationmessage',
name='source_object_id',
field=models.CharField(max_length=255, null=True, blank=True),
),
]

View File

@ -50,7 +50,6 @@ from djmoney.contrib.exchange.models import convert_money
from opentelemetry import trace
from PIL import Image
from rest_framework.exceptions import PermissionDenied
from taggit.managers import TaggableManager
import common.validators
import InvenTree.conversion
@ -1679,7 +1678,7 @@ class NotificationMessage(models.Model):
ContentType, on_delete=models.CASCADE, related_name='notification_target'
)
target_object_id = models.PositiveIntegerField()
target_object_id = models.CharField(max_length=255)
target_object = GenericForeignKey('target_content_type', 'target_object_id')
@ -1692,7 +1691,7 @@ class NotificationMessage(models.Model):
blank=True,
)
source_object_id = models.PositiveIntegerField(null=True, blank=True)
source_object_id = models.CharField(max_length=255, null=True, blank=True)
source_object = GenericForeignKey('source_content_type', 'source_object_id')
@ -1923,7 +1922,11 @@ def rename_attachment(instance, filename: str):
)
class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
class Attachment(
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeModel,
):
"""Class which represents an uploaded file attachment.
An attachment can be either an uploaded file, or an external URL.
@ -2156,8 +2159,6 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
)
tags = TaggableManager(blank=True)
@property
def basename(self):
"""Base name/path for attachment."""

View File

@ -12,6 +12,7 @@ from error_report.models import Error
from flags.state import flag_state
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from taggit.models import Tag
import common.filters
import common.models as common_models
@ -28,6 +29,7 @@ from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
OptionalField,
)
from plugin import registry as plugin_registry
@ -422,6 +424,29 @@ class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria
)
@register_importer()
class TagSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the Tag model."""
class Meta:
"""Meta options for TagSerializer."""
model = Tag
fields = ['pk', 'name', 'slug']
read_only_fields = ['pk', 'slug']
def validate(self, data):
"""Slugify the received name to generate the slug."""
from django.utils.text import slugify
name = data.get('name', None)
if name is not None:
data['slug'] = slugify(name)
return data
@register_importer()
class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the custom state model."""
@ -720,7 +745,9 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
result = serializers.CharField()
class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
class AttachmentSerializer(
FilterableSerializerMixin, InvenTreeTaggitSerializer, InvenTreeModelSerializer
):
"""Serializer class for the Attachment model."""
class Meta:
@ -977,15 +1004,17 @@ class SelectionEntrySerializer(InvenTreeModelSerializer):
def validate(self, attrs):
"""Ensure that the selection list is not locked."""
ret = super().validate(attrs)
if self.instance and self.instance.list.locked:
list_obj = attrs.get('list') or (self.instance and self.instance.list)
if list_obj and list_obj.locked:
raise serializers.ValidationError({'list': _('Selection list is locked')})
return ret
class SelectionListSerializer(InvenTreeModelSerializer):
class SelectionListSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
"""Serializer for a selection list."""
_choices_validated: dict = {}
_choices_provided: bool = False
class Meta:
"""Meta options for SelectionListSerializer."""
@ -1002,81 +1031,25 @@ class SelectionListSerializer(InvenTreeModelSerializer):
'default',
'created',
'last_updated',
'choices',
'entry_count',
'choices',
]
default = SelectionEntrySerializer(read_only=True, allow_null=True, many=False)
choices = SelectionEntrySerializer(source='entries', many=True, required=False)
entry_count = serializers.IntegerField(read_only=True)
choices = OptionalField(
serializer_class=SelectionEntrySerializer,
serializer_kwargs={'source': 'entries', 'many': True, 'read_only': True},
prefetch_fields=['entries'],
default_include=True,
)
@staticmethod
def annotate_queryset(queryset):
"""Add count of entries for each selection list."""
return queryset.annotate(entry_count=Count('entries'))
def is_valid(self, *, raise_exception=False):
"""Validate the selection list. Choices are validated separately."""
choices = (
self.initial_data.pop('choices')
if self.initial_data.get('choices') is not None
else []
)
# Validate the choices
_choices_validated = []
db_entries = (
{a.id: a for a in self.instance.entries.all()} if self.instance else {}
)
for choice in choices:
current_inst = db_entries.get(choice.get('id'))
serializer = SelectionEntrySerializer(
instance=current_inst,
data={'list': current_inst.list.pk if current_inst else None, **choice},
)
serializer.is_valid(raise_exception=raise_exception)
_choices_validated.append({
**serializer.validated_data,
'id': choice.get('id'),
})
self._choices_validated = _choices_validated
return super().is_valid(raise_exception=raise_exception)
def create(self, validated_data):
"""Create a new selection list. Save the choices separately."""
list_entry = common_models.SelectionList.objects.create(**validated_data)
for choice_data in self._choices_validated:
common_models.SelectionListEntry.objects.create(**{
**choice_data,
'list': list_entry,
})
return list_entry
def update(self, instance, validated_data):
"""Update an existing selection list. Save the choices separately."""
inst_mapping = {inst.id: inst for inst in instance.entries.all()}
existing_ids = {a.get('id') for a in self._choices_validated}
# Perform creations and updates.
ret = []
for data in self._choices_validated:
list_inst = data.get('list', None)
inst = inst_mapping.get(data.get('id'))
if inst is None:
if list_inst is None:
data['list'] = instance
ret.append(SelectionEntrySerializer().create(data))
else:
ret.append(SelectionEntrySerializer().update(inst, data))
# Perform deletions.
for entry_id in inst_mapping.keys() - existing_ids:
inst_mapping[entry_id].delete()
return super().update(instance, validated_data)
def validate(self, attrs):
"""Ensure that the selection list is not locked."""
ret = super().validate(attrs)

View File

@ -677,6 +677,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'REPORT_FETCH_URLS': {
'name': _('Report URL Fetching'),
'description': _('Allow fetching of remote URLs when generating reports'),
'default': False,
'validator': bool,
},
'REPORT_LOG_ERRORS': {
'name': _('Log Report Errors'),
'description': _('Log errors which occur when generating reports'),
@ -1251,6 +1257,15 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
('6', _('Saturday')),
],
},
'CALENDAR_HORIZON_MONTHS': {
'name': _('Calendar Horizon'),
'description': _(
'Number of months into the future to display in calendar views'
),
'default': 12,
'validator': [int, MinValueValidator(1)],
'units': _('months'),
},
'TEST_STATION_DATA': {
'name': _('Enable Test Station Data'),
'description': _('Enable test station data collection for test results'),

View File

@ -13,6 +13,19 @@ import InvenTree.ready
logger = structlog.get_logger('inventree')
def plugin_setting_overrides(slug: str) -> dict:
"""Return a dictionary of plugin setting overrides for the given plugin slug.
These values are set via environment variables or configuration file.
"""
from django.conf import settings
if hasattr(settings, 'PLUGIN_SETTING_OVERRIDES'):
return settings.PLUGIN_SETTING_OVERRIDES.get(slug, {}) or {}
return {}
def global_setting_overrides() -> dict:
"""Return a dictionary of global settings overrides.

View File

@ -8,8 +8,10 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from PIL import Image
from taggit.models import Tag
import common.models
from common.models import SelectionList, SelectionListEntry
from InvenTree.unit_test import InvenTreeAPITestCase
@ -999,3 +1001,236 @@ class AttachmentThumbnailAPITests(InvenTreeAPITestCase):
set_global_setting(
'INVENTREE_UPLOAD_MAX_SIZE', original_limit, change_user=None
)
class TagAPITests(InvenTreeAPITestCase):
"""Tests for the Tag API endpoints and tag-based filtering."""
roles = 'all'
LIST_URL = 'api-tag-list'
DETAIL_URL = 'api-tag-detail'
def setUp(self):
"""Create a small set of tagged objects for filter testing."""
super().setUp()
from part.models import Part
self.part_a = Part.objects.create(
name='Tagged Part A', description='Part with apple and banana tags'
)
self.part_b = Part.objects.create(
name='Tagged Part B', description='Part with apple tag only'
)
self.part_c = Part.objects.create(
name='Untagged Part C', description='Part with no tags'
)
self.part_a.tags.add('apple', 'banana')
self.part_b.tags.add('apple')
# ------------------------------------------------------------------
# Tag list / CRUD
# ------------------------------------------------------------------
def test_tag_list(self):
"""Tag list endpoint should return all existing tags."""
url = reverse(self.LIST_URL)
response = self.get(url)
names = {t['name'] for t in response.data}
self.assertIn('apple', names)
self.assertIn('banana', names)
def test_tag_create(self):
"""Staff users should be able to create tags via POST."""
url = reverse(self.LIST_URL)
n = Tag.objects.count()
response = self.post(url, {'name': 'cherry'}, expected_code=201)
self.assertEqual(response.data['name'], 'cherry')
self.assertEqual(Tag.objects.count(), n + 1)
def test_tag_create_non_staff(self):
"""Non-staff users must not be able to create tags."""
self.user.is_staff = False
self.user.save()
url = reverse(self.LIST_URL)
self.post(url, {'name': 'forbidden'}, expected_code=403)
def test_tag_edit(self):
"""Staff users should be able to rename a tag via PATCH."""
tag = Tag.objects.get(name='banana')
url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk})
response = self.patch(url, {'name': 'blueberry'}, expected_code=200)
self.assertEqual(response.data['name'], 'blueberry')
tag.refresh_from_db()
self.assertEqual(tag.name, 'blueberry')
def test_tag_delete(self):
"""Staff users should be able to delete a tag."""
tag = Tag.objects.get(name='banana')
url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk})
self.delete(url, expected_code=204)
self.assertFalse(Tag.objects.filter(name='banana').exists())
def test_tag_search(self):
"""The list endpoint should support free-text search."""
url = reverse(self.LIST_URL)
response = self.get(url, data={'search': 'app'})
names = [t['name'] for t in response.data]
self.assertIn('apple', names)
self.assertNotIn('banana', names)
# ------------------------------------------------------------------
# Filter by model type
# ------------------------------------------------------------------
def test_tag_filter_model_type(self):
"""Tags applied to a given model type should be returned when filtering by model_type."""
url = reverse(self.LIST_URL)
# Filter for tags applied to Part objects
response = self.get(url, data={'model_type': 'part.part'})
names = {t['name'] for t in response.data}
self.assertIn('apple', names)
self.assertIn('banana', names)
def test_tag_filter_model_type_unrelated(self):
"""Filtering by a model type that has no tagged objects should return an empty list."""
url = reverse(self.LIST_URL)
# StockItem has no tagged objects in this test
response = self.get(url, data={'model_type': 'stock.stockitem'})
self.assertEqual(len(response.data), 0)
def test_tag_filter_model_type_invalid(self):
"""An unrecognised model_type value should return a 400 error."""
url = reverse(self.LIST_URL)
self.get(url, data={'model_type': 'notanapp.notamodel'}, expected_code=400)
# ------------------------------------------------------------------
# Filter Part list by tags
# ------------------------------------------------------------------
def test_part_filter_single_tag(self):
"""Filtering parts by a single tag should return only parts with that tag."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'apple'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertIn(self.part_b.pk, pks)
self.assertNotIn(self.part_c.pk, pks)
def test_part_filter_multiple_tags_and(self):
"""Filtering by comma-separated tags should return only parts that have ALL tags."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'apple,banana'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertNotIn(self.part_b.pk, pks) # only has 'apple'
self.assertNotIn(self.part_c.pk, pks) # no tags at all
def test_part_filter_tag_case_insensitive(self):
"""Tag filtering should be case-insensitive."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'APPLE'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertIn(self.part_b.pk, pks)
def test_part_filter_nonexistent_tag(self):
"""Filtering by a tag that no part has should return an empty result set."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'doesnotexist'})
self.assertEqual(len(response.data), 0)
def test_part_filter_tag_whitespace(self):
"""Whitespace around comma-separated tag names should be ignored."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': ' apple , banana '})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertNotIn(self.part_b.pk, pks)
class SelectionListLockedTest(InvenTreeAPITestCase):
"""Tests that a locked SelectionList rejects all entry mutations."""
def setUp(self):
"""Create a locked SelectionList with one entry."""
super().setUp()
self.sel_list = SelectionList.objects.create(name='Locked List', locked=True)
self.entry = SelectionListEntry.objects.create(
list=self.sel_list, value='v1', label='Entry 1'
)
self.list_url = reverse(
'api-selectionlist-detail', kwargs={'pk': self.sel_list.pk}
)
self.entry_list_url = reverse(
'api-selectionlistentry-list', kwargs={'pk': self.sel_list.pk}
)
self.entry_detail_url = reverse(
'api-selectionlistentry-detail',
kwargs={'pk': self.sel_list.pk, 'entrypk': self.entry.pk},
)
def test_create_entry_locked(self):
"""POST a new entry to a locked list should be rejected."""
response = self.post(
self.entry_list_url,
{'list': self.sel_list.pk, 'value': 'v2', 'label': 'Entry 2'},
expected_code=400,
)
self.assertIn('list', response.data)
self.assertIn('locked', str(response.data['list']).lower())
def test_update_entry_locked(self):
"""PATCH an entry on a locked list should be rejected."""
response = self.patch(
self.entry_detail_url, {'label': 'Changed'}, expected_code=400
)
self.assertIn('list', response.data)
self.assertIn('locked', str(response.data['list']).lower())
def test_delete_entry_locked(self):
"""DELETE an entry from a locked list should be rejected."""
self.delete(self.entry_detail_url, expected_code=403)
self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists())
def test_patch_list_with_choices_locked(self):
"""PATCH the list with a choices payload should be rejected when locked."""
response = self.patch(
self.list_url,
{'choices': [{'value': 'v2', 'label': 'New'}]},
expected_code=400,
)
self.assertIn('locked', response.data)
def test_patch_list_without_choices_preserves_entries(self):
"""PATCH the list without choices should not touch entries (even when unlocked)."""
self.sel_list.locked = False
self.sel_list.save()
self.patch(self.list_url, {'name': 'Renamed List'}, expected_code=200)
# Entry must still exist — omitting choices must not delete entries
self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists())

View File

@ -2103,43 +2103,58 @@ class SelectionListTest(InvenTreeAPITestCase):
# Test adding a new list via the API
response = self.post(
reverse('api-selectionlist-list'),
{
'name': 'New List',
'active': True,
'choices': [{'value': '1', 'label': 'Test Entry'}],
},
{'name': 'New List', 'active': True},
expected_code=201,
)
list_pk = response.data['pk']
self.assertEqual(response.data['name'], 'New List')
self.assertTrue(response.data['active'])
entry_list_url = reverse('api-selectionlistentry-list', kwargs={'pk': list_pk})
# Add an entry via the entry API
response = self.post(
entry_list_url,
{'list': list_pk, 'value': '1', 'label': 'Test Entry'},
expected_code=201,
)
entry_pk = response.data['id']
self.assertEqual(response.data['value'], '1')
self.assertEqual(response.data['label'], 'Test Entry')
# Verify the entry appears in the list's choices
response = self.get(
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
data={'choices': True},
expected_code=200,
)
self.assertEqual(len(response.data['choices']), 1)
self.assertEqual(response.data['choices'][0]['value'], '1')
# Test editing the list choices via the API (remove and add in same call)
response = self.patch(
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
{'choices': [{'value': '2', 'label': 'New Label'}]},
expected_code=200,
# Edit the entry via the entry detail API
entry_url = reverse(
'api-selectionlistentry-detail', kwargs={'pk': list_pk, 'entrypk': entry_pk}
)
self.assertEqual(response.data['name'], 'New List')
self.assertTrue(response.data['active'])
self.assertEqual(len(response.data['choices']), 1)
self.assertEqual(response.data['choices'][0]['value'], '2')
self.assertEqual(response.data['choices'][0]['label'], 'New Label')
entry_id = response.data['choices'][0]['id']
response = self.patch(entry_url, {'label': 'Updated Label'}, expected_code=200)
self.assertEqual(response.data['value'], '1')
self.assertEqual(response.data['label'], 'Updated Label')
# Test changing an entry via list API
response = self.patch(
# Add a second entry, then delete the first via the entry detail API
self.post(
entry_list_url,
{'list': list_pk, 'value': '2', 'label': 'Second Entry'},
expected_code=201,
)
self.delete(entry_url, expected_code=204)
# Verify only the second entry remains
response = self.get(
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
{'choices': [{'id': entry_id, 'value': '2', 'label': 'New Label Text'}]},
data={'choices': True},
expected_code=200,
)
self.assertEqual(response.data['name'], 'New List')
self.assertTrue(response.data['active'])
self.assertEqual(len(response.data['choices']), 1)
self.assertEqual(response.data['choices'][0]['value'], '2')
self.assertEqual(response.data['choices'][0]['label'], 'New Label Text')
def test_api_locked(self):
"""Test editing with locked/unlocked list."""

View File

@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
import django_filters.rest_framework.filters as rest_filters
from django_filters.rest_framework.filterset import FilterSet
import common.filters
import part.models
from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path
@ -37,6 +38,18 @@ from .serializers import (
)
class CompanyFilter(FilterSet):
"""Custom API filters for the CompanyList endpoint."""
class Meta:
"""Metaclass options."""
model = Company
fields = ['is_customer', 'is_manufacturer', 'is_supplier', 'name', 'active']
tags = common.filters.TagsFilter()
class CompanyMixin(OutputOptionsMixin):
"""Mixin class for Company API endpoints."""
@ -62,13 +75,7 @@ class CompanyList(CompanyMixin, ParameterListMixin, DataExportViewMixin, ListCre
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [
'is_customer',
'is_manufacturer',
'is_supplier',
'name',
'active',
]
filterset_class = CompanyFilter
search_fields = ['name', 'description', 'website', 'tax_id']
@ -145,6 +152,8 @@ class ManufacturerPartFilter(FilterSet):
field_name='manufacturer__active', label=_('Manufacturer is Active')
)
tags = common.filters.TagsFilter(label=_('Tags'))
class ManufacturerOutputOptions(OutputConfiguration):
"""Available output options for the ManufacturerPart endpoints."""
@ -299,6 +308,8 @@ class SupplierPartFilter(FilterSet):
else:
return queryset.exclude(in_stock__gt=0)
tags = common.filters.TagsFilter(label=_('Tags'))
class SupplierPartOutputOptions(OutputConfiguration):
"""Available output options for the SupplierPart endpoints."""

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.14 on 2026-06-03 10:07
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("company", "0079_auto_20260212_1054"),
(
"taggit",
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
),
]
operations = [
migrations.AddField(
model_name="company",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -17,7 +17,6 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy as __
from moneyed import CURRENCIES
from taggit.managers import TaggableManager
import common.currency
import common.models
@ -80,6 +79,7 @@ class Company(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeImageMixin,
InvenTree.models.InvenTreeMetadataModel,
@ -487,6 +487,7 @@ class ManufacturerPart(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeMetadataModel,
):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
@ -559,8 +560,6 @@ class ManufacturerPart(
help_text=_('Manufacturer part description'),
)
tags = TaggableManager(blank=True)
@classmethod
def create(cls, part, manufacturer, mpn, description, link=None):
"""Check if ManufacturerPart instance does not already exist then create it."""
@ -603,6 +602,7 @@ class SupplierPart(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin,
common.models.MetaMixin,
InvenTree.models.InvenTreeModel,
@ -621,7 +621,6 @@ class SupplierPart(
note: Longer form note field
base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee"
multiple: Multiple that the part is provided in
lead_time: Supplier lead time
packaging: packaging that the part is supplied in, e.g. "Reel"
pack_quantity: Quantity of item supplied in a single pack (e.g. 30ml in a single tube)
pack_quantity_native: Pack quantity, converted to "native" units of the referenced part
@ -640,8 +639,6 @@ class SupplierPart(
# This model was moved from the 'Part' app
db_table = 'part_supplierpart'
tags = TaggableManager(blank=True)
@staticmethod
def get_api_url():
"""Return the API URL associated with the SupplierPart model."""

View File

@ -20,7 +20,7 @@ from InvenTree.serializers import (
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTagModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
)
@ -108,6 +108,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
class CompanySerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
@ -143,6 +144,7 @@ class CompanySerializer(
'primary_address',
'tax_id',
'parameters',
'tags',
]
@staticmethod
@ -185,6 +187,8 @@ class CompanySerializer(
help_text=_('Default currency used for this supplier'), required=True
)
tags = common.filters.enable_tags_filter()
parameters = common.filters.enable_parameters_filter()
@ -207,8 +211,9 @@ class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerialize
class ManufacturerPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTagModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for ManufacturerPart object."""
@ -308,8 +313,9 @@ class SupplierPriceBreakBriefSerializer(
class SupplierPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTagModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for SupplierPart object."""

View File

@ -236,6 +236,12 @@ ldap:
# INVENTREE_DEFAULT_CURRENCY: 'CNY'
# INVENTREE_RESTRICT_ABOUT: true
# Override plugin settings (by plugin slug)
# Settings specified here will be locked and cannot be changed via the UI
#plugin_settings:
# plugin-slug:
# SETTING_KEY: 'value'
# Storage configuration
# Ref: https://docs.inventree.org/en/stable/start/config/#storage-backends
storage:

View File

@ -115,6 +115,10 @@ class DataImportSessionAcceptFields(APIView):
"""Accept the field mapping for a DataImportSession."""
session = get_object_or_404(importer.models.DataImportSession, pk=pk)
# Check session ownership
if not request.user.is_staff and session.user != request.user:
raise PermissionDenied()
# Check that the user has permission to accept the field mapping
if model_class := session.model_class:
if not check_user_permission(request.user, model_class, 'change'):
@ -137,17 +141,45 @@ class DataImportSessionAcceptRows(DataImporterPermissionMixin, CreateAPI):
ctx = super().get_serializer_context()
try:
ctx['session'] = importer.models.DataImportSession.objects.get(
session = importer.models.DataImportSession.objects.get(
pk=self.kwargs.get('pk', None)
)
except Exception:
pass
except importer.models.DataImportSession.DoesNotExist:
session = None
if session:
user = self.request.user
if not user.is_staff and session.user != user:
raise PermissionDenied()
ctx['session'] = session
ctx['request'] = self.request
return ctx
class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
class DataImportSessionChildMixin(DataImporterPermissionMixin):
"""Mixin for DataImportRow and DataImportColumnMap views.
Ensures users can only access objects that belong to an import session they own.
Staff users retain access to all objects.
"""
def get_queryset(self):
"""Return only objects whose session belongs to the requesting user."""
queryset = super().get_queryset()
try:
user = self.request.user
except AttributeError:
raise PermissionDenied('User information is not available')
if user.is_staff:
return queryset
return queryset.filter(session__user=user)
class DataImportColumnMappingList(DataImportSessionChildMixin, ListAPI):
"""API endpoint for accessing a list of DataImportColumnMap objects."""
queryset = importer.models.DataImportColumnMap.objects.all()
@ -158,14 +190,14 @@ class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
filterset_fields = ['session']
class DataImportColumnMappingDetail(DataImporterPermissionMixin, RetrieveUpdateAPI):
class DataImportColumnMappingDetail(DataImportSessionChildMixin, RetrieveUpdateAPI):
"""Detail endpoint for a single DataImportColumnMap object."""
queryset = importer.models.DataImportColumnMap.objects.all()
serializer_class = importer.serializers.DataImportColumnMapSerializer
class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI):
class DataImportRowList(DataImportSessionChildMixin, BulkDeleteMixin, ListAPI):
"""API endpoint for accessing a list of DataImportRow objects."""
queryset = importer.models.DataImportRow.objects.all()
@ -180,7 +212,7 @@ class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI):
ordering = 'row_index'
class DataImportRowDetail(DataImporterPermissionMixin, RetrieveUpdateDestroyAPI):
class DataImportRowDetail(DataImportSessionChildMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single DataImportRow object."""
queryset = importer.models.DataImportRow.objects.all()

View File

@ -0,0 +1,24 @@
# Generated migration
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("importer", "0005_dataimportsession_update_records"),
]
operations = [
migrations.AddField(
model_name="dataimportcolumnmap",
name="lookup_field",
field=models.CharField(
blank=True,
null=True,
max_length=100,
verbose_name="Lookup Field",
help_text="Database field to use for foreign-key lookup. Leave blank for automatic lookup.",
),
),
]

View File

@ -152,6 +152,36 @@ class DataImportSession(models.Model):
return supported_models().get(self.model_type, None)
def get_lookup_fields_for_field(self, field_name: str) -> list:
"""Return the valid lookup fields for a given related (FK) field.
Returns a list of field names that can be used as a lookup key,
consisting of 'pk' plus any fields defined in IMPORT_ID_FIELDS on the related model.
"""
model = self.get_related_model(field_name)
if not model:
return ['pk']
id_fields = ['pk']
if custom_fields := getattr(model, 'IMPORT_ID_FIELDS', None):
id_fields += custom_fields
return id_fields
@property
def field_lookup_mapping(self) -> dict:
"""Return a dict of field -> lookup_field mappings for this import session.
Only entries where lookup_field is explicitly set are included.
"""
return {
mapping.field: mapping.lookup_field
for mapping in self.column_mappings.all()
if mapping.lookup_field
}
def get_related_model(self, field_name: str) -> Optional[models.Model]:
"""Return the related model for a given field name.
@ -344,14 +374,21 @@ class DataImportSession(models.Model):
self.save()
def check_complete(self) -> bool:
"""Check if the import session is complete."""
"""Check if the import session is complete.
When all rows have been accepted, the rows and column mappings are
deleted as they are no longer needed. The session itself is retained
as an audit record.
"""
if self.completed_row_count < self.row_count:
return False
# Update the status of this session
if self.status != DataImportStatusCode.COMPLETE.value:
self.status = DataImportStatusCode.COMPLETE.value
self.save()
# Clear staging data now that all rows have been imported
self.rows.all().delete()
self.column_mappings.all().delete()
return True
@ -401,6 +438,11 @@ class DataImportSession(models.Model):
if field.get('read_only', False):
continue
if field.get('type') == 'related field':
field['lookup_fields'] = self.get_lookup_fields_for_field(
field_name
)
fields[field_name] = field
# Cache the available fields against this instance
@ -488,6 +530,22 @@ class DataImportColumnMap(models.Model):
if field_def.get('read_only', False):
raise DjangoValidationError({'field': _('Selected field is read-only')})
if self.lookup_field:
if field_def.get('type') != 'related field':
raise DjangoValidationError({
'lookup_field': _(
'Lookup field can only be set for related (foreign-key) fields'
)
})
valid_lookup_fields = self.session.get_lookup_fields_for_field(self.field)
if self.lookup_field not in valid_lookup_fields:
raise DjangoValidationError({
'lookup_field': _(
'Invalid lookup field. Valid options are: {options}'
).format(options=', '.join(valid_lookup_fields))
})
session = models.ForeignKey(
DataImportSession,
on_delete=models.CASCADE,
@ -499,6 +557,16 @@ class DataImportColumnMap(models.Model):
column = models.CharField(blank=True, max_length=100, verbose_name=_('Column'))
lookup_field = models.CharField(
blank=True,
null=True,
max_length=100,
verbose_name=_('Lookup Field'),
help_text=_(
'Database field to use for foreign-key lookup. Leave blank for automatic lookup.'
),
)
@property
def available_fields(self):
"""Return a list of available fields for this import session.
@ -631,9 +699,12 @@ class DataImportRow(models.Model):
default_values = self.default_values
data = {}
extract_errors = {}
self.related_field_map = {}
field_lookup_mapping = self.session.field_lookup_mapping
# We have mapped column (file) to field (serializer) already
for field, col in field_mapping.items():
# Data override (force value and skip any further checks)
@ -661,7 +732,13 @@ class DataImportRow(models.Model):
elif field_type == 'date':
value = self.convert_date_field(value)
elif field_type == 'related field':
value = self.lookup_related_field(field, value)
try:
value = self.lookup_related_field(
field, value, lookup_field=field_lookup_mapping.get(field)
)
except DjangoValidationError as exc:
extract_errors[field] = exc.message
continue
# Use the default value, if provided
if value is None and field in default_values:
@ -703,6 +780,9 @@ class DataImportRow(models.Model):
self.data = data
if extract_errors:
self.errors = extract_errors
if commit:
self.save()
@ -726,7 +806,9 @@ class DataImportRow(models.Model):
# If none of the formats matched, return the original value
return value
def lookup_related_field(self, field_name: str, value: str) -> Optional[int]:
def lookup_related_field(
self, field_name: str, value: str, lookup_field: Optional[str] = None
) -> Optional[int]:
"""Try to perform lookup against a related field.
- This is used to convert a human-readable value (e.g. a supplier name) into a database reference (e.g. supplier ID).
@ -735,6 +817,7 @@ class DataImportRow(models.Model):
Arguments:
field_name: The name of the field to perform the lookup against
value: The value to be looked up
lookup_field: If provided, only query this specific model field (skips auto-lookup)
Returns:
A primary key value
@ -758,21 +841,35 @@ class DataImportRow(models.Model):
'session': f'No related model found for field: {field_name}'
})
valid_items = set()
base_filters = (
self.session.field_filters.get(field_name, {})
if self.session.field_filters
else {}
)
# First priority is the PK (primary key) field
if lookup_field and type(lookup_field) is str:
# A specific lookup field has been chosen by the user — query only that field
try:
queryset = model.objects.filter(**{lookup_field: value}, **base_filters)
except ValueError:
return value
results = list(queryset[:2])
if len(results) == 1:
return results[0].pk
# Zero or multiple results — return raw value and let serializer report the error
return value
# Auto-lookup: try pk first, then any model-defined IMPORT_ID_FIELDS
id_fields = ['pk']
if custom_id_fields := getattr(model, 'IMPORT_ID_FIELDS', None):
id_fields += custom_id_fields
# Iterate through the provided list - if any of the values match, we can perform the lookup
valid_items = set()
for id_field in id_fields:
try:
queryset = model.objects.filter(**{id_field: value}, **base_filters)
@ -782,15 +879,19 @@ class DataImportRow(models.Model):
# Evaluate at most two results to determine if there is exactly one match
results = list(queryset[:2])
if len(results) == 1:
# We have a single match against this field
valid_items.add(results[0].pk)
if len(valid_items) == 1:
# We found a single valid match against the related model - return this value
return valid_items.pop()
# We found either zero or multiple values matching against the related model
# Return the original value and let the serializer validation handle any errors against this field
if len(valid_items) > 1:
raise DjangoValidationError(
_(
'Multiple matches found for value - please ensure the value is unique, or select a specific lookup field'
)
)
# No match found - return the original value and let the serializer validation handle it
return value
def serializer_data(self):
@ -837,6 +938,10 @@ class DataImportRow(models.Model):
# Row has already been completed
return True
if self.errors:
# Errors were set during data extraction (e.g. ambiguous FK lookup)
return False
if self.session.update_records:
# Extract the ID field from the data
instance_id = self.data.get(self.session.ID_FIELD_LABEL, None)

View File

@ -23,7 +23,15 @@ class DataImportColumnMapSerializer(InvenTreeModelSerializer):
"""Meta class options for the serializer."""
model = importer.models.DataImportColumnMap
fields = ['pk', 'session', 'column', 'field', 'label', 'description']
fields = [
'pk',
'session',
'column',
'field',
'label',
'description',
'lookup_field',
]
read_only_fields = ['field', 'session']
label = serializers.CharField(read_only=True)

View File

@ -2,10 +2,11 @@
import os
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.urls import reverse
from importer.models import DataImportRow, DataImportSession
from importer.models import DataImportColumnMap, DataImportRow, DataImportSession
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
@ -58,14 +59,20 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
self.assertEqual(session.rows.count(), 12)
# Check that some data has been imported
for row in session.rows.all():
rows = list(session.rows.all())
self.assertEqual(len(rows), 12)
for row in rows:
self.assertIsNotNone(row.data.get('name', None))
self.assertTrue(row.valid)
row.validate(commit=True)
self.assertTrue(row.complete)
self.assertEqual(session.completed_row_count, 12)
# All rows accepted: rows and mappings are cleared, session is retained
session.refresh_from_db()
self.assertEqual(session.rows.count(), 0)
self.assertEqual(session.column_mappings.count(), 0)
# Check that the new companies have been created
self.assertEqual(n + 12, Company.objects.count())
@ -73,6 +80,72 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
def test_field_defaults(self):
"""Test default field values."""
def test_lookup_field_ambiguous_match(self):
"""Test the behavior of lookup_related_field for ambiguous and pinned matches."""
from django.core.exceptions import ValidationError as DjangoValidationError
from part.models import Part, PartCategory
category = PartCategory.objects.create(
name='Test Category', description='Test category'
)
# Two parts which collide under different IMPORT_ID_FIELDS ('IPN' and 'name')
part_a = Part.objects.create(
category=category, name='Widget', description='desc', IPN='AMBIG-001'
)
Part.objects.create(
category=category, name='AMBIG-001', description='desc', IPN='WIDGET-002'
)
data_file = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=data_file, model_type='stockitem'
)
row = DataImportRow(session=session)
row.related_field_map = {}
# Auto-lookup (no pinned lookup field) raises, as the value matches two different parts
with self.assertRaises(DjangoValidationError):
row.lookup_related_field('part', 'AMBIG-001')
# Pinning the lookup field to 'IPN' resolves the match unambiguously
result = row.lookup_related_field('part', 'AMBIG-001', lookup_field='IPN')
self.assertEqual(result, part_a.pk)
# Pinning the lookup field to 'name' resolves to the *other* part
result = row.lookup_related_field('part', 'AMBIG-001', lookup_field='name')
self.assertNotEqual(result, part_a.pk)
def test_lookup_field_validation(self):
"""Test that DataImportColumnMap.clean() validates the lookup_field value."""
from django.core.exceptions import ValidationError as DjangoValidationError
data_file = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=data_file, model_type='stockitem'
)
part_mapping = session.column_mappings.get(field='part')
quantity_mapping = session.column_mappings.get(field='quantity')
# Valid lookup field for a related (FK) field
part_mapping.lookup_field = 'IPN'
part_mapping.save()
part_mapping.refresh_from_db()
self.assertEqual(part_mapping.lookup_field, 'IPN')
# Invalid lookup field (not a valid IMPORT_ID_FIELDS option)
part_mapping.lookup_field = 'not_a_real_field'
with self.assertRaises(DjangoValidationError):
part_mapping.save()
# lookup_field cannot be set against a non-related field
quantity_mapping.lookup_field = 'IPN'
with self.assertRaises(DjangoValidationError):
quantity_mapping.save()
class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
"""End-to-end tests for the importer API."""
@ -174,6 +247,58 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
# Check that there are new database records
self.assertEqual(PartCategory.objects.count(), N + 4)
def test_column_mapping_lookup_field(self):
"""Test that the 'lookup_field' option can be specified via the column-mapping API."""
f = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=f, model_type='stockitem', user=self.user
)
self.assignRole('stock.change')
# available_fields should expose the valid lookup field options for the 'part' FK field
session_detail = self.get(
reverse('api-import-session-detail', kwargs={'pk': session.pk})
).data
part_field_info = session_detail['available_fields']['part']
self.assertIn('lookup_fields', part_field_info)
self.assertIn('IPN', part_field_info['lookup_fields'])
self.assertIn('name', part_field_info['lookup_fields'])
self.assertIn('pk', part_field_info['lookup_fields'])
part_mapping = session.column_mappings.get(field='part')
quantity_mapping = session.column_mappings.get(field='quantity')
mapping_url = reverse(
'api-importer-mapping-detail', kwargs={'pk': part_mapping.pk}
)
# Initially, no lookup field is set (defaults to 'auto')
data = self.get(mapping_url).data
self.assertIn(data['lookup_field'], [None, ''])
# Set a valid lookup field
data = self.patch(mapping_url, {'lookup_field': 'IPN'}, expected_code=200).data
self.assertEqual(data['lookup_field'], 'IPN')
# Confirm it persists
data = self.get(mapping_url).data
self.assertEqual(data['lookup_field'], 'IPN')
# An invalid lookup field is rejected
self.patch(mapping_url, {'lookup_field': 'not_a_real_field'}, expected_code=400)
# Clear the lookup field (revert to 'auto')
data = self.patch(mapping_url, {'lookup_field': None}, expected_code=200).data
self.assertIn(data['lookup_field'], [None, ''])
# lookup_field cannot be set against a non-related field
quantity_url = reverse(
'api-importer-mapping-detail', kwargs={'pk': quantity_mapping.pk}
)
self.patch(quantity_url, {'lookup_field': 'IPN'}, expected_code=400)
def test_session_list(self):
"""Test API endpoint which details the list of import sessions."""
url = reverse('api-importer-session-list')
@ -204,6 +329,191 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
for session in response.data:
self.assertEqual(session['user'], self.user.pk)
def test_accept_fields_ownership(self):
"""Test that accept_fields rejects requests for sessions owned by another user."""
other_user = User.objects.create_user(
username='other_accept', password='password'
)
f = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=f, model_type='company', user=other_user
)
url = reverse('api-import-session-accept-fields', kwargs={'pk': session.pk})
# Non-owner, non-staff should be denied
self.user.is_staff = False
self.user.save()
self.post(url, expected_code=403)
# Staff should be allowed (subject to model permission)
# Company is part of the purchase_order ruleset
self.user.is_staff = True
self.user.save()
self.assignRole('purchase_order.change')
self.post(url, expected_code=200)
def test_accept_rows_ownership(self):
"""Test that accept_rows rejects requests for sessions owned by another user."""
other_user = User.objects.create_user(
username='other_accept_rows', password='password'
)
f = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=f, model_type='company', user=other_user
)
session.extract_columns()
url = reverse('api-import-session-accept-rows', kwargs={'pk': session.pk})
self.user.is_staff = False
self.user.save()
self.post(url, {'rows': []}, expected_code=403)
# Staff can reach the endpoint (rows list is empty so validation rejects with 400, not 403)
self.user.is_staff = True
self.user.save()
self.post(url, {'rows': []}, expected_code=400)
def test_session_cleanup_on_complete(self):
"""Test that a completed import session deletes itself and all associated data."""
url = reverse('api-importer-session-list')
data_file = self.helper_file('part_categories.csv')
data = self.post(
url,
{'model_type': 'partcategory', 'data_file': data_file},
format='multipart',
).data
session_id = data['pk']
session_pk = session_id
self.assignRole('part_category.add')
self.post(
reverse('api-import-session-accept-fields', kwargs={'pk': session_id}),
expected_code=200,
)
rows = self.get(
reverse('api-importer-row-list'), data={'session': session_id}
).data
row_ids = [r['pk'] for r in rows]
self.assertGreater(len(row_ids), 0)
# Confirm rows and mappings exist before acceptance
self.assertTrue(DataImportRow.objects.filter(session_id=session_pk).exists())
self.assertTrue(
DataImportColumnMap.objects.filter(session_id=session_pk).exists()
)
# Accept all rows — this should trigger cleanup of rows and mappings
self.post(
reverse('api-import-session-accept-rows', kwargs={'pk': session_id}),
{'rows': row_ids},
)
# Rows and column mappings must be cleared
self.assertFalse(DataImportRow.objects.filter(session_id=session_pk).exists())
self.assertFalse(
DataImportColumnMap.objects.filter(session_id=session_pk).exists()
)
# Session itself is retained as an audit record with COMPLETE status
from importer.models import DataImportSession
from importer.status_codes import DataImportStatusCode
session_obj = DataImportSession.objects.get(pk=session_pk)
self.assertEqual(session_obj.status, DataImportStatusCode.COMPLETE.value)
detail = self.get(
reverse('api-import-session-detail', kwargs={'pk': session_id}),
expected_code=200,
).data
self.assertEqual(detail['row_count'], 0)
self.assertEqual(detail['completed_row_count'], 0)
def test_row_and_mapping_ownership(self):
"""Test that DataImportRow and DataImportColumnMap endpoints filter by session ownership."""
f = self.helper_file('companies.csv')
other_user = User.objects.create_user(
username='other_importer', password='password'
)
# Session owned by self.user
session_mine = DataImportSession.objects.create(
data_file=f, model_type='company', user=self.user
)
session_mine.extract_columns()
# Session owned by another user
f2 = self.helper_file('companies.csv')
session_other = DataImportSession.objects.create(
data_file=f2, model_type='company', user=other_user
)
session_other.extract_columns()
row_list_url = reverse('api-importer-row-list')
mapping_list_url = reverse('api-importer-mapping-list')
# Non-staff: should only see rows/mappings from own session
self.user.is_staff = False
self.user.save()
rows = self.get(row_list_url).data
for row in rows:
self.assertEqual(row['session'], session_mine.pk)
mappings = self.get(mapping_list_url).data
for mapping in mappings:
self.assertEqual(mapping['session'], session_mine.pk)
# Detail endpoint: own session's row/mapping should be accessible
own_row = DataImportRow.objects.filter(session=session_mine).first()
other_row = DataImportRow.objects.filter(session=session_other).first()
if own_row:
self.get(
reverse('api-importer-row-detail', kwargs={'pk': own_row.pk}),
expected_code=200,
)
if other_row:
self.get(
reverse('api-importer-row-detail', kwargs={'pk': other_row.pk}),
expected_code=404,
)
own_mapping = DataImportColumnMap.objects.filter(session=session_mine).first()
other_mapping = DataImportColumnMap.objects.filter(
session=session_other
).first()
if own_mapping:
self.get(
reverse('api-importer-mapping-detail', kwargs={'pk': own_mapping.pk}),
expected_code=200,
)
if other_mapping:
self.get(
reverse('api-importer-mapping-detail', kwargs={'pk': other_mapping.pk}),
expected_code=404,
)
# Staff user: should see rows/mappings from all sessions
self.user.is_staff = True
self.user.save()
all_row_pks = set(DataImportRow.objects.values_list('pk', flat=True))
response_rows = self.get(row_list_url).data
self.assertEqual({r['pk'] for r in response_rows}, all_row_pks)
all_mapping_pks = set(DataImportColumnMap.objects.values_list('pk', flat=True))
response_mappings = self.get(mapping_list_url).data
self.assertEqual({m['pk'] for m in response_mappings}, all_mapping_pks)
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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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