Merge branch 'master' into switch-pytz

This commit is contained in:
Matthias Mair 2024-07-30 23:24:45 +02:00 committed by GitHub
commit 3e7a3b6d3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
334 changed files with 174173 additions and 125331 deletions

View File

@ -1,5 +1,3 @@
version: "3"
services: services:
db: db:
image: postgres:13 image: postgres:13

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1 @@
blank_issues_enabled: false

View File

@ -18,7 +18,7 @@ updates:
directories: directories:
- /contrib/container - /contrib/container
- /docs - /docs
- /.github - /contrib/dev_reqs
- /src/backend - /src/backend
schedule: schedule:
interval: weekly interval: weekly

View File

@ -97,6 +97,9 @@ if __name__ == '__main__':
) )
text = version_file.read_text() text = version_file.read_text()
results = re.findall(r"""INVENTREE_API_VERSION = (.*)""", text) results = re.findall(r"""INVENTREE_API_VERSION = (.*)""", text)
# If 2. args is true lower the version number by 1
if len(sys.argv) > 2 and sys.argv[2] == 'true':
results[0] = str(int(results[0]) - 1)
print(results[0]) print(results[0])
exit(0) exit(0)
# GITHUB_REF_TYPE may be either 'branch' or 'tag' # GITHUB_REF_TYPE may be either 'branch' or 'tag'

View File

@ -68,7 +68,7 @@ jobs:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Set Up Python ${{ env.python_version }} - name: Set Up Python ${{ env.python_version }}
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0 uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # pin@v5.1.1
with: with:
python-version: ${{ env.python_version }} python-version: ${{ env.python_version }}
- name: Version Check - name: Version Check
@ -124,10 +124,10 @@ jobs:
rm -rf InvenTree/_testfolder rm -rf InvenTree/_testfolder
- name: Set up QEMU - name: Set up QEMU
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/setup-qemu-action@5927c834f5b4fdf503fca6f4c7eccda82949e1ee # pin@v3.1.0 uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # pin@v3.2.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@4fd812986e6c8c2a69e18311145f9371337f27d4 # pin@v3.4.0 uses: docker/setup-buildx-action@aa33708b10e362ff993539393ff100fa93ed6a27 # pin@v3.5.0
- name: Set up cosign - name: Set up cosign
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # pin@v3.5.0 uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # pin@v3.5.0
@ -141,14 +141,14 @@ jobs:
fi fi
- name: Login to Dockerhub - name: Login to Dockerhub
if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true' if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true'
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # pin@v3.2.0 uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # pin@v3.3.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into registry ghcr.io - name: Log into registry ghcr.io
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # pin@v3.2.0 uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # pin@v3.3.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -166,7 +166,7 @@ jobs:
- name: Push Docker Images - name: Push Docker Images
id: push-docker id: push-docker
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/build-push-action@1a162644f9a7e87d8f4b053101d1d9a712edc18c # pin@v6.3.0 uses: docker/build-push-action@5176d81f87c23d6fc96624dfdbcd9f3830bbe445 # pin@v6.5.0
with: with:
context: . context: .
file: ./contrib/container/Dockerfile file: ./contrib/container/Dockerfile

View File

@ -10,7 +10,7 @@ on:
env: env:
python_version: 3.9 python_version: 3.9
node_version: 18 node_version: 20
# The OS version must be set per job # The OS version must be set per job
server_start_sleep: 60 server_start_sleep: 60
@ -94,7 +94,7 @@ jobs:
steps: steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7 - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Set up Python ${{ env.python_version }} - name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0 uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # pin@v5.1.1
with: with:
python-version: ${{ env.python_version }} python-version: ${{ env.python_version }}
cache: "pip" cache: "pip"
@ -115,7 +115,7 @@ jobs:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Set up Python ${{ env.python_version }} - name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0 uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # pin@v5.1.1
with: with:
python-version: ${{ env.python_version }} python-version: ${{ env.python_version }}
- name: Check Config - name: Check Config
@ -164,15 +164,27 @@ jobs:
name: schema.yml name: schema.yml
path: src/backend/InvenTree/schema.yml path: src/backend/InvenTree/schema.yml
- name: Download public schema - name: Download public schema
if: needs.paths-filter.outputs.api == 'false'
run: | run: |
pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1 pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1
version="$(python3 .github/scripts/version_check.py only_version 2>&1)" version="$(python3 .github/scripts/version_check.py only_version ${{ needs.paths-filter.outputs.api }} 2>&1)"
echo "Version: $version" echo "Version: $version"
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml" url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
echo "URL: $url" echo "URL: $url"
curl -s -o api.yaml $url code=$(curl -s -o api.yaml $url --write-out '%{http_code}' --silent)
if [ "$code" != "200" ]; then
exit 1
fi
echo "Downloaded api.yaml" echo "Downloaded api.yaml"
- name: Running OpenAPI Spec diff action
id: breaking_changes
uses: oasdiff/oasdiff-action/diff@205ce7e2c5ae1511e720cbd307cae79fd7d4a909 # pin@main
with:
base: 'api.yaml'
revision: 'src/backend/InvenTree/schema.yml'
format: 'html'
- name: Echoing diff to step
run: echo "${{ steps.breaking_changes.outputs.diff }}" >> $GITHUB_STEP_SUMMARY
- name: Check for differences in API Schema - name: Check for differences in API Schema
if: needs.paths-filter.outputs.api == 'false' if: needs.paths-filter.outputs.api == 'false'
run: | run: |
@ -555,6 +567,8 @@ jobs:
run: cd src/frontend && yarn install run: cd src/frontend && yarn install
- name: Build frontend - name: Build frontend
run: cd src/frontend && yarn run compile && yarn run build run: cd src/frontend && yarn run compile && 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 - name: Zip frontend
run: | run: |
cd src/backend/InvenTree/web/static cd src/backend/InvenTree/web/static

View File

@ -43,6 +43,10 @@ jobs:
run: cd src/frontend && yarn install run: cd src/frontend && yarn install
- name: Build frontend - name: Build frontend
run: cd src/frontend && npm run compile && npm run build run: cd src/frontend && npm run compile && npm run build
- name: Write version file - SHA
run: cd src/backend/InvenTree/web/static/web/.vite && echo "$GITHUB_SHA" > sha.txt
- name: Write version file - TAG
run: cd src/backend/InvenTree/web/static/web/.vite && echo "${{ github.ref_name }}" > tag.txt
- name: Zip frontend - name: Zip frontend
run: | run: |
cd src/backend/InvenTree/web/static/web cd src/backend/InvenTree/web/static/web

View File

@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11 uses: github/codeql-action/upload-sarif@2d790406f505036ef40ecba973cc774a50395aac # v3.25.13
with: with:
sarif_file: results.sarif sarif_file: results.sarif

View File

@ -7,7 +7,7 @@ on:
env: env:
python_version: 3.9 python_version: 3.9
node_version: 18 node_version: 20
permissions: permissions:
contents: read contents: read

View File

@ -14,8 +14,10 @@ env:
- INVENTREE_BACKUP_DIR=/opt/inventree/backup - INVENTREE_BACKUP_DIR=/opt/inventree/backup
- INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt - INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
- INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml - INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
- APP_REPO=inventree/InvenTree
before_install: contrib/packager.io/preinstall.sh before_install: contrib/packager.io/preinstall.sh
after_install: contrib/packager.io/postinstall.sh after_install: contrib/packager.io/postinstall.sh
before_remove: contrib/packager.io/preinstall.sh
before: before:
- contrib/packager.io/before.sh - contrib/packager.io/before.sh
dependencies: dependencies:
@ -33,7 +35,7 @@ dependencies:
- gettext - gettext
- nginx - nginx
- jq - jq
- libffi7 - "libffi7 | libffi8"
targets: targets:
ubuntu-20.04: true ubuntu-20.04: true
debian-11: true debian-11: true

View File

@ -17,7 +17,7 @@ repos:
- id: check-yaml - id: check-yaml
- id: mixed-line-ending - id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.1 rev: v0.5.1
hooks: hooks:
- id: ruff-format - id: ruff-format
args: [--preview] args: [--preview]
@ -27,23 +27,23 @@ repos:
--preview --preview
] ]
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.1.35 rev: 0.2.13
hooks: hooks:
- id: pip-compile - id: pip-compile
name: pip-compile requirements-dev.in name: pip-compile requirements-dev.in
args: [src/backend/requirements-dev.in, -o, src/backend/requirements-dev.txt] args: [src/backend/requirements-dev.in, -o, src/backend/requirements-dev.txt, --no-strip-extras, --generate-hashes]
files: src/backend/requirements-dev\.(in|txt)$ files: src/backend/requirements-dev\.(in|txt)$
- id: pip-compile - id: pip-compile
name: pip-compile requirements.txt name: pip-compile requirements.txt
args: [src/backend/requirements.in, -o, src/backend/requirements.txt] args: [src/backend/requirements.in, -o, src/backend/requirements.txt, --no-strip-extras, --generate-hashes]
files: src/backend/requirements\.(in|txt)$ files: src/backend/requirements\.(in|txt)$
- id: pip-compile - id: pip-compile
name: pip-compile requirements.txt name: pip-compile requirements.txt
args: [contrib/dev_reqs/requirements.in, -o, contrib/dev_reqs/requirements.txt] args: [contrib/dev_reqs/requirements.in, -o, contrib/dev_reqs/requirements.txt, --no-strip-extras, --generate-hashes]
files: contrib/dev_reqs/requirements\.(in|txt)$ files: contrib/dev_reqs/requirements\.(in|txt)$
- id: pip-compile - id: pip-compile
name: pip-compile requirements.txt name: pip-compile requirements.txt
args: [docs/requirements.in, -o, docs/requirements.txt] args: [docs/requirements.in, -o, docs/requirements.txt, --no-strip-extras, --generate-hashes]
files: docs/requirements\.(in|txt)$ files: docs/requirements\.(in|txt)$
- id: pip-compile - id: pip-compile
name: pip-compile requirements.txt name: pip-compile requirements.txt
@ -54,9 +54,11 @@ repos:
hooks: hooks:
- id: djlint-django - id: djlint-django
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell
rev: v2.2.6 rev: v2.3.0
hooks: hooks:
- id: codespell - id: codespell
additional_dependencies:
- tomli
exclude: > exclude: >
(?x)^( (?x)^(
docs/docs/stylesheets/.*| docs/docs/stylesheets/.*|
@ -75,7 +77,7 @@ repos:
- "prettier@^2.4.1" - "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports" - "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint - repo: https://github.com/pre-commit/mirrors-eslint
rev: "v9.4.0" rev: "v9.6.0"
hooks: hooks:
- id: eslint - id: eslint
additional_dependencies: additional_dependencies:
@ -87,7 +89,7 @@ repos:
- "@typescript-eslint/parser" - "@typescript-eslint/parser"
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$ files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
- repo: https://github.com/gitleaks/gitleaks - repo: https://github.com/gitleaks/gitleaks
rev: v8.18.3 rev: v8.18.4
hooks: hooks:
- id: gitleaks - id: gitleaks
#- repo: https://github.com/jumanjihouse/pre-commit-hooks #- repo: https://github.com/jumanjihouse/pre-commit-hooks

26
.vscode/launch.json vendored
View File

@ -6,19 +6,37 @@
"configurations": [ "configurations": [
{ {
"name": "InvenTree Server", "name": "InvenTree Server",
"type": "python", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py", "program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
"args": ["runserver"], "args": [
"runserver",
// "0.0.0.0:8000", // expose server in network (useful for testing with mobile app)
// "--noreload" // disable auto-reload
],
"django": true,
"justMyCode": true
},
{
"name": "InvenTree Server - Tests",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
"args": [
"test",
// "part.test_api.PartCategoryAPITest", // run only a specific test
],
"django": true, "django": true,
"justMyCode": true "justMyCode": true
}, },
{ {
"name": "InvenTree Server - 3rd party", "name": "InvenTree Server - 3rd party",
"type": "python", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py", "program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
"args": ["runserver"], "args": [
"runserver"
],
"django": true, "django": true,
"justMyCode": false "justMyCode": false
}, },

View File

@ -1,5 +1,3 @@
version: "3.8"
# Docker compose recipe for InvenTree development server # Docker compose recipe for InvenTree development server
# - Runs PostgreSQL as the database backend # - Runs PostgreSQL as the database backend
# - Uses built-in django webserver # - Uses built-in django webserver

View File

@ -1,5 +1,3 @@
version: "3.8"
# Docker compose recipe for a production-ready InvenTree setup, with the following containers: # Docker compose recipe for a production-ready InvenTree setup, with the following containers:
# - PostgreSQL as the database backend # - PostgreSQL as the database backend
# - gunicorn as the InvenTree web server # - gunicorn as the InvenTree web server

View File

@ -1,7 +1,7 @@
#!/bin/ash #!/bin/ash
# Install system packages required for building InvenTree python libraries # Install system packages required for building InvenTree python libraries
# Note that for postgreslql, we use the 13 version, which matches the version used in the InvenTree docker image # Note that for postgreslql, we use the version 13, which matches the version used in the InvenTree docker image
apk add gcc g++ musl-dev openssl-dev libffi-dev cargo python3-dev openldap-dev \ apk add gcc g++ musl-dev openssl-dev libffi-dev cargo python3-dev openldap-dev \
libstdc++ build-base linux-headers py3-grpcio \ libstdc++ build-base linux-headers py3-grpcio \

View File

@ -11,12 +11,15 @@ django==4.2.14 \
django-auth-ldap==4.8.0 \ django-auth-ldap==4.8.0 \
--hash=sha256:4b4b944f3c28bce362f33fb6e8db68429ed8fd8f12f0c0c4b1a4344a7ef225ce \ --hash=sha256:4b4b944f3c28bce362f33fb6e8db68429ed8fd8f12f0c0c4b1a4344a7ef225ce \
--hash=sha256:604250938ddc9fda619f247c7a59b0b2f06e53a7d3f46a156f28aa30dd71a738 --hash=sha256:604250938ddc9fda619f247c7a59b0b2f06e53a7d3f46a156f28aa30dd71a738
# via -r contrib/container/requirements.in
gunicorn==22.0.0 \ gunicorn==22.0.0 \
--hash=sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9 \ --hash=sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9 \
--hash=sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63 --hash=sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63
# via -r contrib/container/requirements.in
invoke==2.2.0 \ invoke==2.2.0 \
--hash=sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820 \ --hash=sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820 \
--hash=sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5 --hash=sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5
# via -r contrib/container/requirements.in
mariadb==1.1.10 \ mariadb==1.1.10 \
--hash=sha256:03d6284ef713d1cad40146576a4cc2d6cbc1662060f2a0e59b174e1694521698 \ --hash=sha256:03d6284ef713d1cad40146576a4cc2d6cbc1662060f2a0e59b174e1694521698 \
--hash=sha256:1ce87971c02375236ff8933e6c593c748e7b2f2950b86eabfab4289fd250ea63 \ --hash=sha256:1ce87971c02375236ff8933e6c593c748e7b2f2950b86eabfab4289fd250ea63 \
@ -29,6 +32,7 @@ mariadb==1.1.10 \
--hash=sha256:a332893e3ef7ceb7970ab4bd7c844bcb4bd68a051ca51313566f9808d7411f2d \ --hash=sha256:a332893e3ef7ceb7970ab4bd7c844bcb4bd68a051ca51313566f9808d7411f2d \
--hash=sha256:d7b09ec4abd02ed235257feb769f90cd4066e8f536b55b92f5166103d5b66a63 \ --hash=sha256:d7b09ec4abd02ed235257feb769f90cd4066e8f536b55b92f5166103d5b66a63 \
--hash=sha256:dff8b28ce4044574870d7bdd2d9f9f5da8e5f95a7ff6d226185db733060d1a93 --hash=sha256:dff8b28ce4044574870d7bdd2d9f9f5da8e5f95a7ff6d226185db733060d1a93
# via -r contrib/container/requirements.in
mysqlclient==2.2.4 \ mysqlclient==2.2.4 \
--hash=sha256:329e4eec086a2336fe3541f1ce095d87a6f169d1cc8ba7b04ac68bcb234c9711 \ --hash=sha256:329e4eec086a2336fe3541f1ce095d87a6f169d1cc8ba7b04ac68bcb234c9711 \
--hash=sha256:33bc9fb3464e7d7c10b1eaf7336c5ff8f2a3d3b88bab432116ad2490beb3bf41 \ --hash=sha256:33bc9fb3464e7d7c10b1eaf7336c5ff8f2a3d3b88bab432116ad2490beb3bf41 \
@ -39,6 +43,7 @@ mysqlclient==2.2.4 \
--hash=sha256:ac44777eab0a66c14cb0d38965572f762e193ec2e5c0723bcd11319cc5b693c5 \ --hash=sha256:ac44777eab0a66c14cb0d38965572f762e193ec2e5c0723bcd11319cc5b693c5 \
--hash=sha256:d43987bb9626096a302ca6ddcdd81feaeca65ced1d5fe892a6a66b808326aa54 \ --hash=sha256:d43987bb9626096a302ca6ddcdd81feaeca65ced1d5fe892a6a66b808326aa54 \
--hash=sha256:e1ebe3f41d152d7cb7c265349fdb7f1eca86ccb0ca24a90036cde48e00ceb2ab --hash=sha256:e1ebe3f41d152d7cb7c265349fdb7f1eca86ccb0ca24a90036cde48e00ceb2ab
# via -r contrib/container/requirements.in
packaging==24.0 \ packaging==24.0 \
--hash=sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5 \ --hash=sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5 \
--hash=sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9 --hash=sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9
@ -48,6 +53,7 @@ packaging==24.0 \
psycopg[binary, pool]==3.1.18 \ psycopg[binary, pool]==3.1.18 \
--hash=sha256:31144d3fb4c17d78094d9e579826f047d4af1da6a10427d91dfcfb6ecdf6f12b \ --hash=sha256:31144d3fb4c17d78094d9e579826f047d4af1da6a10427d91dfcfb6ecdf6f12b \
--hash=sha256:4d5a0a5a8590906daa58ebd5f3cfc34091377354a1acced269dd10faf55da60e --hash=sha256:4d5a0a5a8590906daa58ebd5f3cfc34091377354a1acced269dd10faf55da60e
# via -r contrib/container/requirements.in
psycopg-binary==3.1.18 \ psycopg-binary==3.1.18 \
--hash=sha256:02bd4da45d5ee9941432e2e9bf36fa71a3ac21c6536fe7366d1bd3dd70d6b1e7 \ --hash=sha256:02bd4da45d5ee9941432e2e9bf36fa71a3ac21c6536fe7366d1bd3dd70d6b1e7 \
--hash=sha256:0f68ac2364a50d4cf9bb803b4341e83678668f1881a253e1224574921c69868c \ --hash=sha256:0f68ac2364a50d4cf9bb803b4341e83678668f1881a253e1224574921c69868c \
@ -131,7 +137,9 @@ pyasn1-modules==0.4.0 \
# via python-ldap # via python-ldap
python-ldap==3.4.4 \ python-ldap==3.4.4 \
--hash=sha256:7edb0accec4e037797705f3a05cbf36a9fde50d08c8f67f2aef99a2628fab828 --hash=sha256:7edb0accec4e037797705f3a05cbf36a9fde50d08c8f67f2aef99a2628fab828
# via django-auth-ldap # via
# -r contrib/container/requirements.in
# django-auth-ldap
pyyaml==6.0.1 \ pyyaml==6.0.1 \
--hash=sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5 \ --hash=sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5 \
--hash=sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc \ --hash=sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc \
@ -184,9 +192,11 @@ pyyaml==6.0.1 \
--hash=sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585 \ --hash=sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585 \
--hash=sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d \ --hash=sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d \
--hash=sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f --hash=sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f
setuptools==69.5.1 \ # via -r contrib/container/requirements.in
--hash=sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987 \ setuptools==70.3.0 \
--hash=sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32 --hash=sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5 \
--hash=sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc
# via -r contrib/container/requirements.in
sqlparse==0.5.0 \ sqlparse==0.5.0 \
--hash=sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93 \ --hash=sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93 \
--hash=sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663 --hash=sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663
@ -215,6 +225,8 @@ uv==0.1.38 \
--hash=sha256:b0b15e51a0f8240969bc412ed0dd60cfe3f664b30173139ef263d71c596d631f \ --hash=sha256:b0b15e51a0f8240969bc412ed0dd60cfe3f664b30173139ef263d71c596d631f \
--hash=sha256:ea44c07605d1359a7d82bf42706dd86d341f15f4ca2e1f36e51626a7111c2ad5 \ --hash=sha256:ea44c07605d1359a7d82bf42706dd86d341f15f4ca2e1f36e51626a7111c2ad5 \
--hash=sha256:f87c9711493c53d32012a96b49c4d53aabdf7ed666cbf2c3fb55dd402a6b31a8 --hash=sha256:f87c9711493c53d32012a96b49c4d53aabdf7ed666cbf2c3fb55dd402a6b31a8
# via -r contrib/container/requirements.in
wheel==0.43.0 \ wheel==0.43.0 \
--hash=sha256:465ef92c69fa5c5da2d1cf8ac40559a8c940886afcef87dcf14b9470862f1d85 \ --hash=sha256:465ef92c69fa5c5da2d1cf8ac40559a8c940886afcef87dcf14b9470862f1d85 \
--hash=sha256:55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81 --hash=sha256:55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81
# via -r contrib/container/requirements.in

View File

@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command: # This file was autogenerated by uv via the following command:
# uv pip compile contrib/dev_reqs/requirements.in -o contrib/dev_reqs/requirements.txt # uv pip compile contrib/dev_reqs/requirements.in -o contrib/dev_reqs/requirements.txt --no-strip-extras --generate-hashes
certifi==2024.7.4 \ certifi==2024.7.4 \
--hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b \ --hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b \
--hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90 --hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90
@ -103,6 +103,7 @@ idna==3.7 \
jc==1.25.2 \ jc==1.25.2 \
--hash=sha256:26e412a65a478f9da3097653db6277f915cfae5c0f0a3f42026b405936abd358 \ --hash=sha256:26e412a65a478f9da3097653db6277f915cfae5c0f0a3f42026b405936abd358 \
--hash=sha256:97ada193495f79550f06fe0cbfb119ff470bcca57c1cc593a5cdb0008720e0b3 --hash=sha256:97ada193495f79550f06fe0cbfb119ff470bcca57c1cc593a5cdb0008720e0b3
# via -r contrib/dev_reqs/requirements.in
pygments==2.17.2 \ pygments==2.17.2 \
--hash=sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c \ --hash=sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c \
--hash=sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367 --hash=sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367
@ -159,9 +160,11 @@ pyyaml==6.0.1 \
--hash=sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585 \ --hash=sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585 \
--hash=sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d \ --hash=sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d \
--hash=sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f --hash=sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f
# via -r contrib/dev_reqs/requirements.in
requests==2.32.2 \ requests==2.32.2 \
--hash=sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289 \ --hash=sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289 \
--hash=sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c --hash=sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c
# via -r contrib/dev_reqs/requirements.in
ruamel-yaml==0.18.6 \ ruamel-yaml==0.18.6 \
--hash=sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636 \ --hash=sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636 \
--hash=sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b --hash=sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b

View File

@ -75,6 +75,7 @@ root_command() {
;; ;;
"Debian GNU/Linux" | "debian gnu/linux" | Raspbian) "Debian GNU/Linux" | "debian gnu/linux" | Raspbian)
if [[ $VER == "12" ]]; then if [[ $VER == "12" ]]; then
DIST_VER="11"
SUPPORTED=true SUPPORTED=true
elif [[ $VER == "11" ]]; then elif [[ $VER == "11" ]]; then
SUPPORTED=true SUPPORTED=true

View File

@ -5,33 +5,40 @@
set -eu set -eu
VERSION="$APP_PKG_VERSION-$APP_PKG_ITERATION"
echo "Setting VERSION information to $VERSION"
echo "$VERSION" > VERSION
# The sha is the second element in APP_PKG_ITERATION # The sha is the second element in APP_PKG_ITERATION
VERSION="$APP_PKG_VERSION-$APP_PKG_ITERATION"
SHA=$(echo $APP_PKG_ITERATION | cut -d'.' -f2) SHA=$(echo $APP_PKG_ITERATION | cut -d'.' -f2)
# Download info # Download info
echo "Getting info from github for commit $SHA" echo "INFO collection | Getting info from github for commit $SHA"
curl -L \ curl -L -s -f \
-H "Accept: application/vnd.github+json" \ -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \ -H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/InvenTree/InvenTree/commits/$SHA > commit.json https://api.github.com/repos/$APP_REPO/commits/$SHA > commit.json
curl -L \ echo "INFO collection | Got commit.json with size $(wc -c commit.json)"
curl -L -s -f \
-H "Accept: application/vnd.github+json" \ -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \ -H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/InvenTree/InvenTree/commits/$SHA/branches-where-head > branches.json https://api.github.com/repos/$APP_REPO/commits/$SHA/branches-where-head > branches.json
echo "INFO collection | Got branches.json with size $(wc -c branches.json)"
curl -L -s -f \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/$APP_REPO/commits/$APP_PKG_VERSION > tag.json
echo "INFO collection | Got tag.json with size $(wc -c tag.json)"
# Extract info # Extract info
echo "Extracting info from github" echo "INFO extract | Extracting info from github"
DATE=$(jq -r '.commit.committer.date' commit.json) DATE=$(jq -r '.commit.committer.date' commit.json)
BRANCH=$(jq -r '.[].name' branches.json) BRANCH=$(jq -r '.[].name' branches.json)
NODE_ID=$(jq -r '.node_id' commit.json) NODE_ID=$(jq -r '.node_id' commit.json)
SIGNATURE=$(jq -r '.commit.verification.signature' commit.json) SIGNATURE=$(jq -r '.commit.verification.signature' commit.json)
FULL_SHA=$(jq -r '.sha' commit.json)
echo "Write VERSION information" echo "INFO write | Write VERSION information"
echo "$VERSION" > VERSION
echo "INVENTREE_COMMIT_HASH='$SHA'" >> VERSION echo "INVENTREE_COMMIT_HASH='$SHA'" >> VERSION
echo "INVENTREE_COMMIT_SHA='$FULL_SHA'" >> VERSION
echo "INVENTREE_COMMIT_DATE='$DATE'" >> VERSION echo "INVENTREE_COMMIT_DATE='$DATE'" >> VERSION
echo "INVENTREE_PKG_INSTALLER='PKG'" >> VERSION echo "INVENTREE_PKG_INSTALLER='PKG'" >> VERSION
echo "INVENTREE_PKG_BRANCH='$BRANCH'" >> VERSION echo "INVENTREE_PKG_BRANCH='$BRANCH'" >> VERSION
@ -39,5 +46,22 @@ echo "INVENTREE_PKG_TARGET='$TARGET'" >> VERSION
echo "NODE_ID='$NODE_ID'" >> VERSION echo "NODE_ID='$NODE_ID'" >> VERSION
echo "SIGNATURE='$SIGNATURE'" >> VERSION echo "SIGNATURE='$SIGNATURE'" >> VERSION
echo "Written VERSION information" echo "INFO write | Written VERSION information"
echo "### VERSION ###"
cat VERSION cat VERSION
echo "### VERSION ###"
# Try to get frontend
echo "INFO frontend | Trying to get frontend"
# Check if tag sha is the same as the commit sha
TAG_SHA=$(jq -r '.sha' tag.json)
if [ "$TAG_SHA" != "$FULL_SHA" ]; then
echo "INFO frontend | Tag sha '$TAG_SHA' is not the same as commit sha $FULL_SHA, can not download frontend"
else
echo "INFO frontend | Getting frontend from github via tag"
curl https://github.com/$APP_REPO/releases/download/$APP_PKG_VERSION/frontend-build.zip -L -O -f
mkdir -p src/backend/InvenTree/web/static
echo "INFO frontend | Unzipping frontend"
unzip -qq frontend-build.zip -d src/backend/InvenTree/web/static/web
echo "INFO frontend | Unzipped frontend"
fi

View File

@ -60,7 +60,7 @@ function detect_python() {
fi fi
# Try to detect a python between 3.9 and 3.12 in reverse order # Try to detect a python between 3.9 and 3.12 in reverse order
if [ -z "${SETUP_PYTHON}" ]; then if [ -z "$(which ${SETUP_PYTHON})" ]; then
echo "# Trying to detecting python3.${PYTHON_FROM} to python3.${PYTHON_TO} - using newest version" echo "# Trying to detecting python3.${PYTHON_FROM} to python3.${PYTHON_TO} - using newest version"
for i in $(seq $PYTHON_TO -1 $PYTHON_FROM); do for i in $(seq $PYTHON_TO -1 $PYTHON_FROM); do
echo "# Checking for python3.${i}" echo "# Checking for python3.${i}"
@ -318,17 +318,17 @@ function set_env() {
sed -i s=debug:\ True=debug:\ False=g ${INVENTREE_CONFIG_FILE} sed -i s=debug:\ True=debug:\ False=g ${INVENTREE_CONFIG_FILE}
# Database engine # Database engine
sed -i s=#ENGINE:\ sampleengine=ENGINE:\ ${INVENTREE_DB_ENGINE}=g ${INVENTREE_CONFIG_FILE} sed -i s=#\ ENGINE:\ Database\ engine.\ Selection\ from:=ENGINE:\ ${INVENTREE_DB_ENGINE}=g ${INVENTREE_CONFIG_FILE}
# Database name # Database name
sed -i s=#NAME:\ \'/path/to/database\'=NAME:\ \'${INVENTREE_DB_NAME}\'=g ${INVENTREE_CONFIG_FILE} sed -i s=#\ NAME:\ Database\ name=NAME:\ \'${INVENTREE_DB_NAME}\'=g ${INVENTREE_CONFIG_FILE}
# Database user # Database user
sed -i s=#USER:\ sampleuser=USER:\ ${INVENTREE_DB_USER}=g ${INVENTREE_CONFIG_FILE} sed -i s=#\ USER:\ Database\ username\ \(if\ required\)=USER:\ ${INVENTREE_DB_USER}=g ${INVENTREE_CONFIG_FILE}
# Database password # Database password
sed -i s=#PASSWORD:\ samplepassword=PASSWORD:\ ${INVENTREE_DB_PASSWORD}=g ${INVENTREE_CONFIG_FILE} sed -i s=#\ PASSWORD:\ Database\ password\ \(if\ required\)=PASSWORD:\ ${INVENTREE_DB_PASSWORD}=g ${INVENTREE_CONFIG_FILE}
# Database host # Database host
sed -i s=#HOST:\ samplehost=HOST:\ ${INVENTREE_DB_HOST}=g ${INVENTREE_CONFIG_FILE} sed -i s=#\ HOST:\ Database\ host\ address\ \(if\ required\)=HOST:\ ${INVENTREE_DB_HOST}=g ${INVENTREE_CONFIG_FILE}
# Database port # Database port
sed -i s=#PORT:\ 123456=PORT:\ ${INVENTREE_DB_PORT}=g ${INVENTREE_CONFIG_FILE} sed -i s=#\ PORT:\ Database\ host\ port\ \(if\ required\)=PORT:\ ${INVENTREE_DB_PORT}=g ${INVENTREE_CONFIG_FILE}
# Fixing the permissions # Fixing the permissions
chown ${APP_USER}:${APP_GROUP} ${DATA_DIR} ${INVENTREE_CONFIG_FILE} chown ${APP_USER}:${APP_GROUP} ${DATA_DIR} ${INVENTREE_CONFIG_FILE}

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# #
# packager.io preinstall script # packager.io preinstall/preremove script
# #
PATH=${APP_HOME}/env/bin:${APP_HOME}/:/sbin:/bin:/usr/sbin:/usr/bin: PATH=${APP_HOME}/env/bin:${APP_HOME}/:/sbin:/bin:/usr/sbin:/usr/bin:

View File

@ -4,7 +4,11 @@ title: Internal Barcodes
## Internal Barcodes ## Internal Barcodes
InvenTree defines an internal format for generating barcodes for various items. This format uses a simple JSON-style string to uniquely identify an item in the database. InvenTree ships with two integrated internal formats for generating barcodes for various items which are available through the built-in InvenTree Barcode plugin. The used format can be selected through the plugin settings of the InvenTree Barcode plugin.
### 1. JSON-based QR Codes
This format uses a simple JSON-style string to uniquely identify an item in the database.
Some simple examples of this format are shown below: Some simple examples of this format are shown below:
@ -12,10 +16,49 @@ Some simple examples of this format are shown below:
| --- | --- | | --- | --- |
| Part | `{% raw %}{"part": 10}{% endraw %}` | | Part | `{% raw %}{"part": 10}{% endraw %}` |
| Stock Item | `{% raw %}{"stockitem": 123}{% endraw %}` | | Stock Item | `{% raw %}{"stockitem": 123}{% endraw %}` |
| Stock Location | `{% raw %}{"stocklocation": 1}{% endraw %}` |
| Supplier Part | `{% raw %}{"supplierpart": 99}{% endraw %}` | | Supplier Part | `{% raw %}{"supplierpart": 99}{% endraw %}` |
The numerical ID value used is the *Primary Key* (PK) of the particular object in the database. The numerical ID value used is the *Primary Key* (PK) of the particular object in the database.
#### Downsides
1. The JSON format includes binary only characters (`{% raw %}{{% endraw %}` and `{% raw %}"{% endraw %}`) which requires unnecessary use of the binary QR code encoding which means fewer amount of chars can be encoded with the same version of QR code.
2. The model name key has not a fixed length. Some model names are longer than others. E.g. a part QR code with the shortest possible id requires 11 chars, while a stock location QR code with the same id would already require 20 chars, which already requires QR code version 2 and quickly version 3.
!!! info "QR code versions"
There are 40 different qr code versions from 1-40. They all can encode more data than the previous version, but require more "squares". E.g. a V1 QR codes has 21x21 "squares" while a V2 already has 25x25. For more information see [QR code comparison](https://www.qrcode.com/en/about/version.html).
For a more detailed size analysis of the JSON-based QR codes refer to [this issue](https://github.com/inventree/InvenTree/issues/6612).
### 2. Short alphanumeric QR Codes
While JSON-based QR Codes encode all necessary information, they come with the described downsides. This new, short, alphanumeric only format is build to improve those downsides. The basic format uses an alphanumeric string: `INV-??x`
- `INV-` is a constant prefix. This is configurable in the InvenTree Barcode plugins settings per instance to support environments that use multiple instances.
- `??` is a two character alphanumeric (`0-9A-Z $%*+-./:` (45 chars)) code, individual to each model.
- `x` the actual pk of the model.
Now with an overhead of 6 chars for every model, this format supports the following amount of model instances using the described QR code modes:
| QR code mode | Alphanumeric mode | Mixed mode |
| --- | --- | --- |
| v1 M ECL (15%) | `10**14` items (~3.170 items per sec for 1000 years) | `10**20` items (~3.170.979.198 items per sec for 1000 years) |
| v1 Q ECL (25%) | `10**10` items (~0.317 items per sec for 1000 years) | `10**13` items (~317 items per sec for 1000 years) |
| v1 H ECL (30%) | `10**4` items (~100 items per day for 100 days) | `10**3` items (~100 items per day for 10 days (*even worse*)) |
!!! info "QR code mixed mode"
Normally the QR code data is encoded only in one format (binary, alphanumeric, numeric). But the data can also be split into multiple chunks using different formats. This is especially useful with long model ids, because the first 6 chars can be encoded using the alphanumeric mode and the id using the more efficient numeric mode. Mixed mode is used by default, because the `qrcode` template tag uses a default value for optimize of 1.
Some simple examples of this format are shown below:
| Model Type | Example Barcode |
| --- | --- |
| Part | `INV-PA10` |
| Stock Item | `INV-SI123` |
| Stock Location | `INV-SL1` |
| Supplier Part | `INV-SP99` |
## Report Integration ## Report Integration
This barcode format can be used to generate 1D or 2D barcodes (e.g. for [labels and reports](../report/barcodes.md)) This barcode format can be used to generate 1D or 2D barcodes (e.g. for [labels and reports](../report/barcodes.md))

View File

@ -125,7 +125,7 @@ The core software modules are targeting the following versions:
| Python | {{ config.extra.min_python_version }} | Minimum required version | | Python | {{ config.extra.min_python_version }} | Minimum required version |
| Invoke | {{ config.extra.min_invoke_version }} | Minimum required version | | Invoke | {{ config.extra.min_invoke_version }} | Minimum required version |
| Django | {{ config.extra.django_version }} | Pinned version | | Django | {{ config.extra.django_version }} | Pinned version |
| Node | 18 | Only needed for frontend development | | Node | 20 | Only needed for frontend development |
Any other software dependencies are handled by the project package config. Any other software dependencies are handled by the project package config.

View File

@ -67,7 +67,7 @@ If you need to process your queue with background workers, run the `worker` task
You can either only run InvenTree or use the integrated debugger for debugging. Goto the `Run and debug` side panel make sure `InvenTree Server` is selected. Click on the play button on the left. You can either only run InvenTree or use the integrated debugger for debugging. Goto the `Run and debug` side panel make sure `InvenTree Server` is selected. Click on the play button on the left.
!!! tip "Debug with 3rd party" !!! tip "Debug with 3rd party"
Sometimes you need to debug also some 3rd party packages. Just select `InvenTree Servre - 3rd party` Sometimes you need to debug also some 3rd party packages. Just select `InvenTree Server - 3rd party`
You can now set breakpoints and vscode will automatically pause execution if that point is hit. You can see all variables available in that context and evaluate some code with the debugger console at the bottom. Use the play or step buttons to continue execution. You can now set breakpoints and vscode will automatically pause execution if that point is hit. You can see all variables available in that context and evaluate some code with the debugger console at the bottom. Use the play or step buttons to continue execution.

View File

@ -4,7 +4,7 @@ title: Barcode Mixin
## Barcode Plugins ## Barcode Plugins
InvenTree supports decoding of arbitrary barcode data via a **Barcode Plugin** interface. Barcode data POSTed to the `/api/barcode/` endpoint will be supplied to all loaded barcode plugins, and the first plugin to successfully interpret the barcode data will return a response to the client. InvenTree supports decoding of arbitrary barcode data and generation of internal barcode formats via a **Barcode Plugin** interface. Barcode data POSTed to the `/api/barcode/` endpoint will be supplied to all loaded barcode plugins, and the first plugin to successfully interpret the barcode data will return a response to the client.
InvenTree can generate native QR codes to represent database objects (e.g. a single StockItem). This barcode can then be used to perform quick lookup of a stock item or location in the database. A client application (for example the InvenTree mobile app) scans a barcode, and sends the barcode data to the InvenTree server. The server then uses the **InvenTreeBarcodePlugin** (found at `src/backend/InvenTree/plugin/builtin/barcodes/inventree_barcode.py`) to decode the supplied barcode data. InvenTree can generate native QR codes to represent database objects (e.g. a single StockItem). This barcode can then be used to perform quick lookup of a stock item or location in the database. A client application (for example the InvenTree mobile app) scans a barcode, and sends the barcode data to the InvenTree server. The server then uses the **InvenTreeBarcodePlugin** (found at `src/backend/InvenTree/plugin/builtin/barcodes/inventree_barcode.py`) to decode the supplied barcode data.
@ -26,7 +26,7 @@ POST {
### Builtin Plugin ### Builtin Plugin
The InvenTree server includes a builtin barcode plugin which can decode QR codes generated by the server. This plugin is enabled by default. The InvenTree server includes a builtin barcode plugin which can generate and decode the QR codes. This plugin is enabled by default.
::: plugin.builtin.barcodes.inventree_barcode.InvenTreeInternalBarcodePlugin ::: plugin.builtin.barcodes.inventree_barcode.InvenTreeInternalBarcodePlugin
options: options:
@ -39,14 +39,12 @@ The InvenTree server includes a builtin barcode plugin which can decode QR codes
### Example Plugin ### Example Plugin
Please find below a very simple example that is executed each time a barcode is scanned. Please find below a very simple example that is used to return a part if the barcode starts with `PART-`
```python ```python
from django.utils.translation import gettext_lazy as _
from InvenTree.models import InvenTreeBarcodeMixin
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.mixins import BarcodeMixin from plugin.mixins import BarcodeMixin
from part.models import Part
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin): class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
@ -56,16 +54,39 @@ class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
VERSION = "0.0.1" VERSION = "0.0.1"
AUTHOR = "Michael" AUTHOR = "Michael"
status = 0
def scan(self, barcode_data): def scan(self, barcode_data):
if barcode_data.startswith("PART-"):
try:
pk = int(barcode_data.split("PART-")[1])
instance = Part.objects.get(pk=pk)
label = Part.barcode_model_type()
self.status = self.status+1 return {label: instance.format_matched_response()}
print('Started barcode plugin', self.status) except Part.DoesNotExist:
print(barcode_data) pass
response = {}
return response
``` ```
To try it just copy the file to src/InvenTree/plugins and restart the server. Open the scan barcode window and start to scan codes or type in text manually. Each time the timeout is hit the plugin will execute and printout the result. The timeout can be changed in `Settings->Barcode Support->Barcode Input Delay`. To try it just copy the file to src/InvenTree/plugins and restart the server. Open the scan barcode window and start to scan codes or type in text manually. Each time the timeout is hit the plugin will execute and printout the result. The timeout can be changed in `Settings->Barcode Support->Barcode Input Delay`.
### Custom Internal Format
To implement a custom internal barcode format, the `generate(...)` method from the Barcode Mixin needs to be overridden. Then the plugin can be selected at `System Settings > Barcodes > Barcode Generation Plugin`.
```python
from InvenTree.models import InvenTreeBarcodeMixin
from plugin import InvenTreePlugin
from plugin.mixins import BarcodeMixin
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
NAME = "MyInternalBarcode"
TITLE = "My Internal Barcodes"
DESCRIPTION = "support for custom internal barcodes"
VERSION = "0.0.1"
AUTHOR = "InvenTree contributors"
def generate(self, model_instance: InvenTreeBarcodeMixin):
return f'{model_instance.barcode_model_type()}: {model_instance.pk}'
```
!!! info "Scanning implementation required"
The parsing of the custom format needs to be implemented too, so that the scanning of the generated QR codes resolves to the correct part.

View File

@ -0,0 +1,19 @@
---
title: Icon Pack Mixin
---
## IconPackMixin
The IconPackMixin class provides basic functionality for letting plugins expose custom icon packs that are available in the InvenTree UI. This is especially useful to provide a custom crafted icon pack with icons for different location types, e.g. different sizes and styles of drawers, bags, ESD bags, ... which are not available in the standard tabler icons library.
### Sample Plugin
The following example demonstrates how to use the `IconPackMixin` class to add a custom icon pack:
::: plugin.samples.icons.icon_sample.SampleIconPlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []

View File

@ -259,6 +259,31 @@ A shortcut function is provided for rendering an image associated with a Company
*Preview* and *thumbnail* image variations can be rendered for the `company_image` tag, in a similar manner to [part image variations](#image-variations) *Preview* and *thumbnail* image variations can be rendered for the `company_image` tag, in a similar manner to [part image variations](#image-variations)
## Icons
Some models (e.g. part categories and locations) allow to specify a custom icon. To render these icons in a report, there is a `{% raw %}{% icon location.icon %}{% endraw %}` template tag from the report template library available.
This tag renders the required html for the icon.
!!! info "Loading fonts"
Additionally the icon fonts need to be loaded into the template. This can be done using the `{% raw %}{% include_icon_fonts %}{% endraw %}` template tag inside of a style block
!!! tip "Custom classes for styling the icon further"
The icon template tag accepts an optional `class` argument which can be used to apply a custom class to the rendered icon used to style the icon further e.g. positioning it, changing it's size, ... `{% raw %}{% icon location.icon class="my-class" %}{% endraw %}`.
```html
{% raw %}
{% load report %}
{% block style %}
{% include_icon_fonts %}
{% endblock style %}
{% icon location.icon %}
{% endraw %}
```
## InvenTree Logo ## InvenTree Logo
A template tag is provided to load the InvenTree logo image into a report. You can render the logo using the `{% raw %}{% logo_image %}{% endraw %}` tag: A template tag is provided to load the InvenTree logo image into a report. You can render the logo using the `{% raw %}{% logo_image %}{% endraw %}` tag:

View File

@ -297,6 +297,10 @@ Alternatively this location can be specified with the `INVENTREE_BACKUP_DIR` env
InvenTree provides allowance for additional sign-in options. The following options are not enabled by default, and care must be taken by the system administrator when configuring these settings. InvenTree provides allowance for additional sign-in options. The following options are not enabled by default, and care must be taken by the system administrator when configuring these settings.
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_MFA_ENABLED | mfa_enabled | Enable or disable multi-factor authentication support for the InvenTree server | True |
### Single Sign On ### Single Sign On
Single Sign On (SSO) allows users to sign in to InvenTree using a third-party authentication provider. This functionality is provided by the [django-allauth](https://docs.allauth.org/en/latest/) package. Single Sign On (SSO) allows users to sign in to InvenTree using a third-party authentication provider. This functionality is provided by the [django-allauth](https://docs.allauth.org/en/latest/) package.

View File

@ -212,3 +212,86 @@ To start afresh (and completely remove the existing database), run the following
``` ```
docker compose run --rm inventree-server invoke delete-data docker compose run --rm inventree-server invoke delete-data
``` ```
## Install custom packages
To install custom packages to your docker image, a custom docker image can be built and used automatically each time when updating. The following changes need to be applied to the docker compose file:
<details><summary>docker-compose.yml changes</summary>
```diff
diff --git a/docker-compose.yml b/docker-compose.yml
index 8adee63..dc3993c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -69,7 +69,14 @@ services:
# Uses gunicorn as the web server
inventree-server:
# If you wish to specify a particular InvenTree version, do so here
- image: inventree/inventree:${INVENTREE_TAG:-stable}
+ image: inventree/inventree:${INVENTREE_TAG:-stable}-custom
+ pull_policy: never
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: production
+ args:
+ INVENTREE_TAG: ${INVENTREE_TAG:-stable}
# Only change this port if you understand the stack.
# If you change this you have to change:
# - the proxy settings (on two lines)
@@ -88,7 +95,8 @@ services:
# Background worker process handles long-running or periodic tasks
inventree-worker:
# If you wish to specify a particular InvenTree version, do so here
- image: inventree/inventree:${INVENTREE_TAG:-stable}
+ image: inventree/inventree:${INVENTREE_TAG:-stable}-custom
+ pull_policy: never
command: invoke worker
depends_on:
- inventree-server
```
</details>
And the following `Dockerfile` needs to be created:
<details><summary>Dockerfile</summary>
```dockerfile
ARG INVENTREE_TAG
FROM inventree/inventree:${INVENTREE_TAG} as production
# Install whatever dependency is needed here (e.g. git)
RUN apk add --no-cache git
```
</details>
And if additional, development packages are needed e.g. just for building a wheel for a pip package, a multi stage build can be used with the following `Dockerfile`:
<details><summary>Dockerfile</summary>
```dockerfile
ARG INVENTREE_TAG
# prebuild stage - needs a lot of build dependencies
# make sure, the alpine and python version matches the version used in the inventree base image
FROM python:3.11-alpine3.18 as prebuild
# Install whatever development dependency is needed (e.g. cups-dev, gcc, the musl-dev build tools and the pip pycups package)
RUN apk add --no-cache cups-dev gcc musl-dev && \
pip install --user --no-cache-dir pycups
# production image - only install the cups shared library
FROM inventree/inventree:${INVENTREE_TAG} as production
# Install e.g. shared library later available in the final image
RUN apk add --no-cache cups-libs
# Copy the pip wheels from the build stage in the production stage
COPY --from=prebuild /root/.local /root/.local
```
</details>

View File

@ -6,6 +6,18 @@ title: Stock
A stock location represents a physical real-world location where *Stock Items* are stored. Locations are arranged in a cascading manner and each location may contain multiple sub-locations, or stock, or both. A stock location represents a physical real-world location where *Stock Items* are stored. Locations are arranged in a cascading manner and each location may contain multiple sub-locations, or stock, or both.
### Icons
Stock locations can be assigned custom icons (either directly or through [Stock Location Types](#stock-location-type)). When using PUI there is a custom icon picker component available that can help to select the right icon. However in CUI the icon needs to be entered manually.
By default, the tabler icons package (with prefix: `ti`) is available. To manually select an item, search on the [tabler icons](https://tabler.io/icons) page for an icon and copy its name e.g. `bookmark`. Some icons have a filled and an outline version (if no variants are specified, it's an outline variant). Now these values can be put into the format: `<package-prefix>:<icon-name>:<variant>`. E.g. `ti:bookmark:outline` or `ti:bookmark:filled`.
If there are some icons missing in the tabler icons package, users can even install their own custom icon packs through a plugin. See [`IconPackMixin`](../extend/plugins/icon.md).
## Stock Location Type
A stock location type represents a specific type of location (e.g. one specific size of drawer, shelf, ... or box) which can be assigned to multiple stock locations. In the first place, it is used to specify an icon and having the icon in sync for all locations that use this location type, but it also serves as a data field to quickly see what type of location this is. It is planned to add e.g. drawer dimension information to the location type to add a "find a matching, empty stock location" tool.
## Stock Item ## Stock Item
A *Stock Item* is an actual instance of a [*Part*](../part/part.md) item. It represents a physical quantity of the *Part* in a specific location. A *Stock Item* is an actual instance of a [*Part*](../part/part.md) item. It represents a physical quantity of the *Part* in a specific location.

View File

@ -2,7 +2,6 @@
import os import os
import subprocess import subprocess
import textwrap
import requests import requests
import yaml import yaml
@ -54,11 +53,23 @@ def check_link(url) -> bool:
return False return False
def get_build_enviroment() -> str:
"""Returns the branch we are currently building on, based on the environment variables of the various CI platforms."""
# Check if we are in ReadTheDocs
if os.environ.get('READTHEDOCS') == 'True':
return os.environ.get('READTHEDOCS_GIT_IDENTIFIER')
# We are in GitHub Actions
elif os.environ.get('GITHUB_ACTIONS') == 'true':
return os.environ.get('GITHUB_REF')
else:
return 'master'
def define_env(env): def define_env(env):
"""Define custom environment variables for the documentation build process.""" """Define custom environment variables for the documentation build process."""
@env.macro @env.macro
def sourcedir(dirname, branch='master'): def sourcedir(dirname, branch=None):
"""Return a link to a directory within the source code repository. """Return a link to a directory within the source code repository.
Arguments: Arguments:
@ -70,6 +81,9 @@ def define_env(env):
Raises: Raises:
- FileNotFoundError: If the directory does not exist, or the generated URL is invalid - FileNotFoundError: If the directory does not exist, or the generated URL is invalid
""" """
if branch == None:
branch = get_build_enviroment()
if dirname.startswith('/'): if dirname.startswith('/'):
dirname = dirname[1:] dirname = dirname[1:]
@ -94,7 +108,7 @@ def define_env(env):
return url return url
@env.macro @env.macro
def sourcefile(filename, branch='master', raw=False): def sourcefile(filename, branch=None, raw=False):
"""Return a link to a file within the source code repository. """Return a link to a file within the source code repository.
Arguments: Arguments:
@ -106,6 +120,9 @@ def define_env(env):
Raises: Raises:
- FileNotFoundError: If the file does not exist, or the generated URL is invalid - FileNotFoundError: If the file does not exist, or the generated URL is invalid
""" """
if branch == None:
branch = get_build_enviroment()
if filename.startswith('/'): if filename.startswith('/'):
filename = filename[1:] filename = filename[1:]

View File

@ -203,6 +203,7 @@ nav:
- Barcode Mixin: extend/plugins/barcode.md - Barcode Mixin: extend/plugins/barcode.md
- Currency Mixin: extend/plugins/currency.md - Currency Mixin: extend/plugins/currency.md
- Event Mixin: extend/plugins/event.md - Event Mixin: extend/plugins/event.md
- Icon Pack Mixin: extend/plugins/icon.md
- Label Printing Mixin: extend/plugins/label.md - Label Printing Mixin: extend/plugins/label.md
- Locate Mixin: extend/plugins/locate.md - Locate Mixin: extend/plugins/locate.md
- Navigation Mixin: extend/plugins/navigation.md - Navigation Mixin: extend/plugins/navigation.md

View File

@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command: # This file was autogenerated by uv via the following command:
# uv pip compile docs/requirements.in -o docs/requirements.txt # uv pip compile docs/requirements.in -o docs/requirements.txt --no-strip-extras --generate-hashes
anyio==4.3.0 \ anyio==4.3.0 \
--hash=sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8 \ --hash=sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8 \
--hash=sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6 --hash=sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6
@ -284,6 +284,7 @@ mkdocs==1.6.0 \
--hash=sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7 \ --hash=sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7 \
--hash=sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512 --hash=sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512
# via # via
# -r docs/requirements.in
# mkdocs-autorefs # mkdocs-autorefs
# mkdocs-git-revision-date-localized-plugin # mkdocs-git-revision-date-localized-plugin
# mkdocs-include-markdown-plugin # mkdocs-include-markdown-plugin
@ -303,15 +304,19 @@ mkdocs-get-deps==0.2.0 \
mkdocs-git-revision-date-localized-plugin==1.2.5 \ mkdocs-git-revision-date-localized-plugin==1.2.5 \
--hash=sha256:0c439816d9d0dba48e027d9d074b2b9f1d7cd179f74ba46b51e4da7bb3dc4b9b \ --hash=sha256:0c439816d9d0dba48e027d9d074b2b9f1d7cd179f74ba46b51e4da7bb3dc4b9b \
--hash=sha256:d796a18b07cfcdb154c133e3ec099d2bb5f38389e4fd54d3eb516a8a736815b8 --hash=sha256:d796a18b07cfcdb154c133e3ec099d2bb5f38389e4fd54d3eb516a8a736815b8
# via -r docs/requirements.in
mkdocs-include-markdown-plugin==6.0.6 \ mkdocs-include-markdown-plugin==6.0.6 \
--hash=sha256:7c80258b2928563c75cc057a7b9a0014701c40804b1b6aa290f3b4032518b43c \ --hash=sha256:7c80258b2928563c75cc057a7b9a0014701c40804b1b6aa290f3b4032518b43c \
--hash=sha256:7ccafbaa412c1e5d3510c4aff46d1fe64c7a810c01dace4c636253d1aa5bc193 --hash=sha256:7ccafbaa412c1e5d3510c4aff46d1fe64c7a810c01dace4c636253d1aa5bc193
# via -r docs/requirements.in
mkdocs-macros-plugin==1.0.5 \ mkdocs-macros-plugin==1.0.5 \
--hash=sha256:f60e26f711f5a830ddf1e7980865bf5c0f1180db56109803cdd280073c1a050a \ --hash=sha256:f60e26f711f5a830ddf1e7980865bf5c0f1180db56109803cdd280073c1a050a \
--hash=sha256:fe348d75f01c911f362b6d998c57b3d85b505876dde69db924f2c512c395c328 --hash=sha256:fe348d75f01c911f362b6d998c57b3d85b505876dde69db924f2c512c395c328
# via -r docs/requirements.in
mkdocs-material==9.5.24 \ mkdocs-material==9.5.24 \
--hash=sha256:02d5aaba0ee755e707c3ef6e748f9acb7b3011187c0ea766db31af8905078a34 \ --hash=sha256:02d5aaba0ee755e707c3ef6e748f9acb7b3011187c0ea766db31af8905078a34 \
--hash=sha256:e12cd75954c535b61e716f359cf2a5056bf4514889d17161fdebd5df4b0153c6 --hash=sha256:e12cd75954c535b61e716f359cf2a5056bf4514889d17161fdebd5df4b0153c6
# via -r docs/requirements.in
mkdocs-material-extensions==1.3.1 \ mkdocs-material-extensions==1.3.1 \
--hash=sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443 \ --hash=sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443 \
--hash=sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31 --hash=sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31
@ -319,10 +324,13 @@ mkdocs-material-extensions==1.3.1 \
mkdocs-simple-hooks==0.1.5 \ mkdocs-simple-hooks==0.1.5 \
--hash=sha256:dddbdf151a18723c9302a133e5cf79538be8eb9d274e8e07d2ac3ac34890837c \ --hash=sha256:dddbdf151a18723c9302a133e5cf79538be8eb9d274e8e07d2ac3ac34890837c \
--hash=sha256:efeabdbb98b0850a909adee285f3404535117159d5cb3a34f541d6eaa644d50a --hash=sha256:efeabdbb98b0850a909adee285f3404535117159d5cb3a34f541d6eaa644d50a
# via -r docs/requirements.in
mkdocstrings[python]==0.25.1 \ mkdocstrings[python]==0.25.1 \
--hash=sha256:c3a2515f31577f311a9ee58d089e4c51fc6046dbd9e9b4c3de4c3194667fe9bf \ --hash=sha256:c3a2515f31577f311a9ee58d089e4c51fc6046dbd9e9b4c3de4c3194667fe9bf \
--hash=sha256:da01fcc2670ad61888e8fe5b60afe9fee5781017d67431996832d63e887c2e51 --hash=sha256:da01fcc2670ad61888e8fe5b60afe9fee5781017d67431996832d63e887c2e51
# via mkdocstrings-python # via
# -r docs/requirements.in
# mkdocstrings-python
mkdocstrings-python==1.10.2 \ mkdocstrings-python==1.10.2 \
--hash=sha256:38a4fd41953defb458a107033440c229c7e9f98f35a24e84d888789c97da5a63 \ --hash=sha256:38a4fd41953defb458a107033440c229c7e9f98f35a24e84d888789c97da5a63 \
--hash=sha256:e8e596b37f45c09b67bec253e035fe18988af5bbbbf44e0ccd711742eed750e5 --hash=sha256:e8e596b37f45c09b67bec253e035fe18988af5bbbbf44e0ccd711742eed750e5
@ -330,6 +338,7 @@ mkdocstrings-python==1.10.2 \
neoteroi-mkdocs==1.0.5 \ neoteroi-mkdocs==1.0.5 \
--hash=sha256:1f3b372dee79269157361733c0f45b3a89189077078e0e3224d829a144ef3579 \ --hash=sha256:1f3b372dee79269157361733c0f45b3a89189077078e0e3224d829a144ef3579 \
--hash=sha256:29875ef444b08aec5619a384142e16f1b4e851465cab4e380fb2b8ae730fe046 --hash=sha256:29875ef444b08aec5619a384142e16f1b4e851465cab4e380fb2b8ae730fe046
# via -r docs/requirements.in
packaging==24.0 \ packaging==24.0 \
--hash=sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5 \ --hash=sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5 \
--hash=sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9 --hash=sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9

View File

@ -16,7 +16,7 @@ exclude = [
src = ["src/backend/InvenTree"] src = ["src/backend/InvenTree"]
# line-length = 120 # line-length = 120
[tool.ruff.extend-per-file-ignores] [tool.ruff.lint.extend-per-file-ignores]
"__init__.py" = ["D104"] "__init__.py" = ["D104"]
[tool.ruff.lint] [tool.ruff.lint]

View File

@ -1,12 +1,39 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 220 INVENTREE_API_VERSION = 228
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v228 - 2024-07-18 : https://github.com/inventree/InvenTree/pull/7684
- Adds "icon" field to the PartCategory.path and StockLocation.path API
- Adds icon packages API endpoint
v227 - 2024-07-19 : https://github.com/inventree/InvenTree/pull/7693/
- Adds endpoints to list and revoke the tokens issued to the current user
v226 - 2024-07-15 : https://github.com/inventree/InvenTree/pull/7648
- Adds barcode generation API endpoint
v225 - 2024-07-17 : https://github.com/inventree/InvenTree/pull/7671
- Adds "filters" field to DataImportSession API
v224 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7667
- Add notes field to ManufacturerPart and SupplierPart API endpoints
v223 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7649
- Allow adjustment of "packaging" field when receiving items against a purchase order
v222 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7635
- Adjust the BomItem API endpoint to improve data import process
v221 - 2024-07-13 : https://github.com/inventree/InvenTree/pull/7636
- Adds missing fields from StockItemBriefSerializer
- Adds missing fields from PartBriefSerializer
- Adds extra exportable fields to BuildItemSerializer
v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585 v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585
- Adds "revision_of" field to Part serializer - Adds "revision_of" field to Part serializer
- Adds new API filters for "revision" status - Adds new API filters for "revision" status

View File

@ -15,6 +15,7 @@ from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
from allauth.core.exceptions import ImmediateHttpResponse from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter from allauth_2fa.adapter import OTPAdapter
from allauth_2fa.forms import TOTPDeviceForm
from allauth_2fa.utils import user_has_valid_totp_device from allauth_2fa.utils import user_has_valid_totp_device
from crispy_forms.bootstrap import AppendedText, PrependedAppendedText, PrependedText from crispy_forms.bootstrap import AppendedText, PrependedAppendedText, PrependedText
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
@ -211,6 +212,16 @@ class CustomSignupForm(SignupForm):
return cleaned_data return cleaned_data
class CustomTOTPDeviceForm(TOTPDeviceForm):
"""Ensure that db registration is enabled."""
def __init__(self, user, metadata=None, **kwargs):
"""Override to check if registration is open."""
if not settings.MFA_ENABLED:
raise forms.ValidationError(_('MFA Registration is disabled.'))
super().__init__(user, metadata, **kwargs)
def registration_enabled(): def registration_enabled():
"""Determine whether user registration is enabled.""" """Determine whether user registration is enabled."""
if get_global_setting('LOGIN_ENABLE_REG') or InvenTree.sso.registration_enabled(): if get_global_setting('LOGIN_ENABLE_REG') or InvenTree.sso.registration_enabled():

View File

@ -396,38 +396,6 @@ def WrapWithQuotes(text, quote='"'):
return text return text
def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs):
"""Generate a string for a barcode. Adds some global InvenTree parameters.
Args:
cls_name: string describing the object type e.g. 'StockItem'
object_pk (int): ID (Primary Key) of the object in the database
object_data: Python dict object containing extra data which will be rendered to string (must only contain stringable values)
Returns:
json string of the supplied data plus some other data
"""
if object_data is None:
object_data = {}
brief = kwargs.get('brief', True)
data = {}
if brief:
data[cls_name] = object_pk
else:
data['tool'] = 'InvenTree'
data['version'] = InvenTree.version.inventreeVersion()
data['instance'] = InvenTree.version.inventreeInstanceName()
# Ensure PK is included
object_data['id'] = object_pk
data[cls_name] = object_data
return str(json.dumps(data, sort_keys=True))
def GetExportFormats(): def GetExportFormats():
"""Return a list of allowable file formats for importing or exporting tabular data.""" """Return a list of allowable file formats for importing or exporting tabular data."""
return ['csv', 'xlsx', 'tsv', 'json'] return ['csv', 'xlsx', 'tsv', 'json']

View File

@ -15,9 +15,6 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money from djmoney.money import Money
from PIL import Image from PIL import Image
import InvenTree
import InvenTree.helpers_model
import InvenTree.version
from common.notifications import ( from common.notifications import (
InvenTreeNotificationBodies, InvenTreeNotificationBodies,
NotificationBody, NotificationBody,
@ -331,9 +328,7 @@ def notify_users(
'instance': instance, 'instance': instance,
'name': content.name.format(**content_context), 'name': content.name.format(**content_context),
'message': content.message.format(**content_context), 'message': content.message.format(**content_context),
'link': InvenTree.helpers_model.construct_absolute_url( 'link': construct_absolute_url(instance.get_absolute_url()),
instance.get_absolute_url()
),
'template': {'subject': content.name.format(**content_context)}, 'template': {'subject': content.name.format(**content_context)},
} }

View File

@ -24,6 +24,7 @@ LOCALES = [
('en', _('English')), ('en', _('English')),
('es', _('Spanish')), ('es', _('Spanish')),
('es-mx', _('Spanish (Mexican)')), ('es-mx', _('Spanish (Mexican)')),
('et', _('Estonian')),
('fa', _('Farsi / Persian')), ('fa', _('Farsi / Persian')),
('fi', _('Finnish')), ('fi', _('Finnish')),
('fr', _('French')), ('fr', _('French')),

View File

@ -0,0 +1,192 @@
"""Custom management command to migrate the old FontAwesome icons."""
import json
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from django.db import models
from common.icons import validate_icon
from part.models import PartCategory
from stock.models import StockLocation, StockLocationType
class Command(BaseCommand):
"""Generate an icon map from the FontAwesome library to the new icon library."""
help = """Helper command to migrate the old FontAwesome icons to the new icon library."""
def add_arguments(self, parser):
"""Add the arguments."""
parser.add_argument(
'--output-file',
type=str,
help='Path to file to write generated icon map to',
)
parser.add_argument(
'--input-file', type=str, help='Path to file to read icon map from'
)
parser.add_argument(
'--include-items',
default=False,
action='store_true',
help='Include referenced inventree items in the output icon map (optional)',
)
parser.add_argument(
'--import-now',
default=False,
action='store_true',
help='CAUTION: If this flag is set, the icon map will be imported and the database will be touched',
)
def handle(self, *args, **kwargs):
"""Generate an icon map from the FontAwesome library to the new icon library."""
# Check for invalid combinations of arguments
if kwargs['output_file'] and kwargs['input_file']:
raise CommandError('Cannot specify both --input-file and --output-file')
if not kwargs['output_file'] and not kwargs['input_file']:
raise CommandError('Must specify either --input-file or --output-file')
if kwargs['include_items'] and not kwargs['output_file']:
raise CommandError(
'--include-items can only be used with an --output-file specified'
)
if kwargs['output_file'] and kwargs['import_now']:
raise CommandError(
'--import-now can only be used with an --input-file specified'
)
ICON_MODELS = [
(StockLocation, 'custom_icon'),
(StockLocationType, 'icon'),
(PartCategory, '_icon'),
]
def get_model_items_with_icons(model: models.Model, icon_field: str):
"""Return a list of models with icon fields."""
return model.objects.exclude(**{f'{icon_field}__isnull': True}).exclude(**{
f'{icon_field}__exact': ''
})
# Generate output icon map file
if kwargs['output_file']:
icons = {}
for model, icon_name in ICON_MODELS:
self.stdout.write(
f'Processing model {model.__name__} with icon field {icon_name}'
)
items = get_model_items_with_icons(model, icon_name)
for item in items:
icon = getattr(item, icon_name)
try:
validate_icon(icon)
continue # Skip if the icon is already valid
except ValidationError:
pass
if icon not in icons:
icons[icon] = {
**({'items': []} if kwargs['include_items'] else {}),
'new_icon': '',
}
if kwargs['include_items']:
icons[icon]['items'].append({
'model': model.__name__.lower(),
'id': item.id, # type: ignore
})
self.stdout.write(f'Writing icon map for {len(icons.keys())} icons')
with open(kwargs['output_file'], 'w') as f:
json.dump(icons, f, indent=2)
self.stdout.write(f'Icon map written to {kwargs["output_file"]}')
# Import icon map file
if kwargs['input_file']:
with open(kwargs['input_file'], 'r') as f:
icons = json.load(f)
self.stdout.write(f'Loaded icon map for {len(icons.keys())} icons')
self.stdout.write('Verifying icon map')
has_errors = False
# Verify that all new icons are valid icons
for old_icon, data in icons.items():
try:
validate_icon(data.get('new_icon', ''))
except ValidationError:
self.stdout.write(
f'[ERR] Invalid icon: "{old_icon}" -> "{data.get("new_icon", "")}'
)
has_errors = True
# Verify that all required items are provided in the icon map
for model, icon_name in ICON_MODELS:
self.stdout.write(
f'Processing model {model.__name__} with icon field {icon_name}'
)
items = get_model_items_with_icons(model, icon_name)
for item in items:
icon = getattr(item, icon_name)
try:
validate_icon(icon)
continue # Skip if the icon is already valid
except ValidationError:
pass
if icon not in icons:
self.stdout.write(
f' [ERR] Icon "{icon}" not found in icon map'
)
has_errors = True
# If there are errors, stop here
if has_errors:
self.stdout.write(
'[ERR] Icon map has errors, please fix them before continuing with importing'
)
return
# Import the icon map into the database if the flag is set
if kwargs['import_now']:
self.stdout.write('Start importing icons and updating database...')
cnt = 0
for model, icon_name in ICON_MODELS:
self.stdout.write(
f'Processing model {model.__name__} with icon field {icon_name}'
)
items = get_model_items_with_icons(model, icon_name)
for item in items:
icon = getattr(item, icon_name)
try:
validate_icon(icon)
continue # Skip if the icon is already valid
except ValidationError:
pass
setattr(item, icon_name, icons[icon]['new_icon'])
cnt += 1
item.save()
self.stdout.write(
f'Icon map successfully imported - changed {cnt} items'
)
self.stdout.write('Icons are now migrated')
else:
self.stdout.write('Icon map is valid and ready to be imported')
self.stdout.write(
'Run the command with --import-now to import the icon map and update the database'
)

View File

@ -3,9 +3,7 @@
import logging import logging
from datetime import datetime from datetime import datetime
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@ -577,6 +575,9 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
# e.g. for StockLocation, this value is 'location' # e.g. for StockLocation, this value is 'location'
ITEM_PARENT_KEY = None ITEM_PARENT_KEY = None
# Extra fields to include in the get_path result. E.g. icon
EXTRA_PATH_FIELDS = []
class Meta: class Meta:
"""Metaclass defines extra model properties.""" """Metaclass defines extra model properties."""
@ -870,7 +871,14 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
name: <name>, name: <name>,
} }
""" """
return [{'pk': item.pk, 'name': item.name} for item in self.path] return [
{
'pk': item.pk,
'name': item.name,
**{k: getattr(item, k, None) for k in self.EXTRA_PATH_FIELDS},
}
for item in self.path
]
def __str__(self): def __str__(self):
"""String representation of a category is the full path to that category.""" """String representation of a category is the full path to that category."""
@ -934,6 +942,8 @@ class InvenTreeBarcodeMixin(models.Model):
- barcode_data : Raw data associated with an assigned barcode - barcode_data : Raw data associated with an assigned barcode
- barcode_hash : A 'hash' of the assigned barcode data used to improve matching - barcode_hash : A 'hash' of the assigned barcode data used to improve matching
The barcode_model_type_code() classmethod must be implemented in the model class.
""" """
class Meta: class Meta:
@ -964,11 +974,25 @@ class InvenTreeBarcodeMixin(models.Model):
# By default, use the name of the class # By default, use the name of the class
return cls.__name__.lower() return cls.__name__.lower()
@classmethod
def barcode_model_type_code(cls):
r"""Return a 'short' code for the model type.
This is used to generate a efficient QR code for the model type.
It is expected to match this pattern: [0-9A-Z $%*+-.\/:]{2}
Note: Due to the shape constrains (45**2=2025 different allowed codes)
this needs to be explicitly implemented in the model class to avoid collisions.
"""
raise NotImplementedError(
'barcode_model_type_code() must be implemented in the model class'
)
def format_barcode(self, **kwargs): def format_barcode(self, **kwargs):
"""Return a JSON string for formatting a QR code for this model instance.""" """Return a JSON string for formatting a QR code for this model instance."""
return InvenTree.helpers.MakeBarcode( from plugin.base.barcodes.helper import generate_barcode
self.__class__.barcode_model_type(), self.pk, **kwargs
) return generate_barcode(self)
def format_matched_response(self): def format_matched_response(self):
"""Format a standard response for a matched barcode.""" """Format a standard response for a matched barcode."""
@ -986,7 +1010,7 @@ class InvenTreeBarcodeMixin(models.Model):
@property @property
def barcode(self): def barcode(self):
"""Format a minimal barcode string (e.g. for label printing).""" """Format a minimal barcode string (e.g. for label printing)."""
return self.format_barcode(brief=True) return self.format_barcode()
@classmethod @classmethod
def lookup_barcode(cls, barcode_hash): def lookup_barcode(cls, barcode_hash):

View File

@ -115,6 +115,7 @@ def canAppAccessDatabase(
'makemessages', 'makemessages',
'compilemessages', 'compilemessages',
'spectactular', 'spectactular',
'collectstatic',
] ]
if not allow_shell: if not allow_shell:
@ -125,7 +126,7 @@ def canAppAccessDatabase(
excluded_commands.append('test') excluded_commands.append('test')
if not allow_plugins: if not allow_plugins:
excluded_commands.extend(['collectstatic', 'collectplugins']) excluded_commands.extend(['collectplugins'])
for cmd in excluded_commands: for cmd in excluded_commands:
if cmd in sys.argv: if cmd in sys.argv:

View File

@ -1210,6 +1210,9 @@ ACCOUNT_FORMS = {
'reset_password_from_key': 'allauth.account.forms.ResetPasswordKeyForm', 'reset_password_from_key': 'allauth.account.forms.ResetPasswordKeyForm',
'disconnect': 'allauth.socialaccount.forms.DisconnectForm', 'disconnect': 'allauth.socialaccount.forms.DisconnectForm',
} }
ALLAUTH_2FA_FORMS = {'setup': 'InvenTree.forms.CustomTOTPDeviceForm'}
# Determine if multi-factor authentication is enabled for this server (default = True)
MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True)
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter' SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter' ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'

View File

@ -3,6 +3,7 @@
import logging import logging
from importlib import import_module from importlib import import_module
from django.conf import settings
from django.urls import NoReverseMatch, include, path, reverse from django.urls import NoReverseMatch, include, path, reverse
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
@ -177,7 +178,9 @@ class SocialProviderListView(ListAPI):
data = { data = {
'sso_enabled': InvenTree.sso.login_enabled(), 'sso_enabled': InvenTree.sso.login_enabled(),
'sso_registration': InvenTree.sso.registration_enabled(), 'sso_registration': InvenTree.sso.registration_enabled(),
'mfa_required': get_global_setting('LOGIN_ENFORCE_MFA'), 'mfa_required': settings.MFA_ENABLED
and get_global_setting('LOGIN_ENFORCE_MFA'),
'mfa_enabled': settings.MFA_ENABLED,
'providers': provider_list, 'providers': provider_list,
'registration_enabled': get_global_setting('LOGIN_ENABLE_REG'), 'registration_enabled': get_global_setting('LOGIN_ENABLE_REG'),
'password_forgotten_enabled': get_global_setting('LOGIN_ENABLE_PWD_FORGOT'), 'password_forgotten_enabled': get_global_setting('LOGIN_ENABLE_PWD_FORGOT'),

View File

@ -1101,3 +1101,14 @@ a {
.large-treeview-icon { .large-treeview-icon {
font-size: 1em; font-size: 1em;
} }
.api-icon {
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better font rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2024 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long

View File

@ -62,6 +62,7 @@ class APITests(InvenTreeAPITestCase):
"""Tests for the InvenTree API.""" """Tests for the InvenTree API."""
fixtures = ['location', 'category', 'part', 'stock'] fixtures = ['location', 'category', 'part', 'stock']
roles = ['part.view']
token = None token = None
auto_login = False auto_login = False
@ -132,6 +133,7 @@ class APITests(InvenTreeAPITestCase):
# Now log in! # Now log in!
self.basicAuth() self.basicAuth()
self.assignRole('part.view')
response = self.get(url) response = self.get(url)
@ -147,12 +149,17 @@ class APITests(InvenTreeAPITestCase):
role_names = roles.keys() role_names = roles.keys()
# By default, 'view' permissions are provided # By default, no permissions are provided
for rule in RuleSet.RULESET_NAMES: for rule in RuleSet.RULESET_NAMES:
self.assertIn(rule, role_names) self.assertIn(rule, role_names)
self.assertIn('view', roles[rule]) if roles[rule] is None:
continue
if rule == 'part':
self.assertIn('view', roles[rule])
else:
self.assertNotIn('view', roles[rule])
self.assertNotIn('add', roles[rule]) self.assertNotIn('add', roles[rule])
self.assertNotIn('change', roles[rule]) self.assertNotIn('change', roles[rule])
self.assertNotIn('delete', roles[rule]) self.assertNotIn('delete', roles[rule])
@ -297,6 +304,7 @@ class SearchTests(InvenTreeAPITestCase):
'order', 'order',
'sales_order', 'sales_order',
] ]
roles = ['build.view', 'part.view']
def test_empty(self): def test_empty(self):
"""Test empty request.""" """Test empty request."""
@ -331,6 +339,19 @@ class SearchTests(InvenTreeAPITestCase):
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}}, {'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
expected_code=200, expected_code=200,
) )
self.assertEqual(
response.data['purchaseorder'],
{'error': 'User does not have permission to view this model'},
)
# Add permissions and try again
self.assignRole('purchase_order.view')
self.assignRole('sales_order.view')
response = self.post(
reverse('api-search'),
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
expected_code=200,
)
self.assertEqual(response.data['purchaseorder']['count'], 1) self.assertEqual(response.data['purchaseorder']['count'], 1)
self.assertEqual(response.data['salesorder']['count'], 0) self.assertEqual(response.data['salesorder']['count'], 0)

View File

@ -71,6 +71,7 @@ class MiddlewareTests(InvenTreeTestCase):
def test_error_exceptions(self): def test_error_exceptions(self):
"""Test that ignored errors are not logged.""" """Test that ignored errors are not logged."""
self.assignRole('part.view')
def check(excpected_nbr=0): def check(excpected_nbr=0):
# Check that errors are empty # Check that errors are empty

View File

@ -789,33 +789,6 @@ class TestIncrement(TestCase):
self.assertEqual(result, b) self.assertEqual(result, b)
class TestMakeBarcode(TestCase):
"""Tests for barcode string creation."""
def test_barcode_extended(self):
"""Test creation of barcode with extended data."""
bc = helpers.MakeBarcode(
'part', 3, {'id': 3, 'url': 'www.google.com'}, brief=False
)
self.assertIn('part', bc)
self.assertIn('tool', bc)
self.assertIn('"tool": "InvenTree"', bc)
data = json.loads(bc)
self.assertEqual(data['part']['id'], 3)
self.assertEqual(data['part']['url'], 'www.google.com')
def test_barcode_brief(self):
"""Test creation of simple barcode."""
bc = helpers.MakeBarcode('stockitem', 7)
data = json.loads(bc)
self.assertEqual(len(data), 1)
self.assertEqual(data['stockitem'], 7)
class TestDownloadFile(TestCase): class TestDownloadFile(TestCase):
"""Tests for DownloadFile.""" """Tests for DownloadFile."""

View File

@ -204,6 +204,7 @@ class UserMixin:
ruleset.can_add = True ruleset.can_add = True
ruleset.save() ruleset.save()
if not assign_all:
break break

View File

@ -115,11 +115,18 @@ class Build(
return defaults return defaults
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return "BO"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Custom save method for the BuildOrder model""" """Custom save method for the BuildOrder model"""
self.validate_reference_field(self.reference) self.validate_reference_field(self.reference)
self.reference_int = self.rebuild_reference_field(self.reference) self.reference_int = self.rebuild_reference_field(self.reference)
# Check part when initially creating the build order
if not self.pk or self.has_field_changed('part'):
if get_global_setting('BUILDORDER_REQUIRE_VALID_BOM'): if get_global_setting('BUILDORDER_REQUIRE_VALID_BOM'):
# Check that the BOM is valid # Check that the BOM is valid
if not self.part.is_bom_valid(): if not self.part.is_bom_valid():

View File

@ -1072,6 +1072,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'part_name', 'part_name',
'part_ipn', 'part_ipn',
'available_quantity', 'available_quantity',
'item_batch_code',
'item_serial',
] ]
class Meta: class Meta:
@ -1103,6 +1105,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'part_name', 'part_name',
'part_ipn', 'part_ipn',
'available_quantity', 'available_quantity',
'item_batch_code',
'item_serial_number',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1138,6 +1142,9 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True) part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True)
part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True) part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True)
item_batch_code = serializers.CharField(source='stock_item.batch', label=_('Batch Code'), read_only=True)
item_serial_number = serializers.CharField(source='stock_item.serial', label=_('Serial Number'), read_only=True)
# Annotated fields # Annotated fields
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True) build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)

View File

@ -277,7 +277,7 @@ src="{% static 'img/blank_image.png' %}"
$('#show-qr-code').click(function() { $('#show-qr-code').click(function() {
showQRDialog( showQRDialog(
'{% trans "Build Order QR Code" escape %}', '{% trans "Build Order QR Code" escape %}',
'{"build": {{ build.pk }} }' '{{ build.barcode }}'
); );
}); });

View File

@ -1015,7 +1015,7 @@ class BuildOverallocationTest(BuildAPITest):
'accept_overallocated': 'trim', 'accept_overallocated': 'trim',
}, },
expected_code=201, expected_code=201,
max_query_count=550, # TODO: Come back and refactor this max_query_count=555, # TODO: Come back and refactor this
) )
self.build.refresh_from_db() self.build.refresh_from_db()

View File

@ -9,6 +9,7 @@ from django.http.response import HttpResponse
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
import django_q.models import django_q.models
@ -25,6 +26,7 @@ from rest_framework.views import APIView
import common.models import common.models
import common.serializers import common.serializers
from common.icons import get_icon_packs
from common.settings import get_global_setting from common.settings import get_global_setting
from generic.states.api import AllStatusViews, StatusView from generic.states.api import AllStatusViews, StatusView
from importer.mixins import DataExportViewMixin from importer.mixins import DataExportViewMixin
@ -743,6 +745,18 @@ class AttachmentDetail(RetrieveUpdateDestroyAPI):
return super().destroy(request, *args, **kwargs) return super().destroy(request, *args, **kwargs)
@method_decorator(cache_control(public=True, max_age=86400), name='dispatch')
class IconList(ListAPI):
"""List view for available icon packages."""
serializer_class = common.serializers.IconPackageSerializer
permission_classes = [permissions.AllowAny]
def get_queryset(self):
"""Return a list of all available icon packages."""
return get_icon_packs().values()
settings_api_urls = [ settings_api_urls = [
# User settings # User settings
path( path(
@ -957,6 +971,8 @@ common_api_urls = [
path('', ContentTypeList.as_view(), name='api-contenttype-list'), path('', ContentTypeList.as_view(), name='api-contenttype-list'),
]), ]),
), ),
# Icons
path('icons/', IconList.as_view(), name='api-icon-list'),
] ]
admin_api_urls = [ admin_api_urls = [

View File

@ -0,0 +1,114 @@
"""Icon utilities for InvenTree."""
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict
from django.core.exceptions import ValidationError
from django.templatetags.static import static
logger = logging.getLogger('inventree')
_icon_packs = None
class Icon(TypedDict):
"""Dict type for an icon.
Attributes:
name: The name of the icon.
category: The category of the icon.
tags: A list of tags for the icon (used for search).
variants: A dictionary of variants for the icon, where the key is the variant name and the value is the variant's unicode hex character.
"""
name: str
category: str
tags: list[str]
variants: dict[str, str]
@dataclass
class IconPack:
"""Dataclass for an icon pack.
Attributes:
name: The name of the icon pack.
prefix: The prefix used for the icon pack.
fonts: A dictionary of different font file formats for the icon pack, where the key is the css format and the value a path to the font file.
icons: A dictionary of icons in the icon pack, where the key is the icon name and the value is a dictionary of the icon's variants.
"""
name: str
prefix: str
fonts: dict[str, str]
icons: dict[str, Icon]
def get_icon_packs():
"""Return a dictionary of available icon packs including their icons."""
global _icon_packs
if _icon_packs is None:
tabler_icons_path = Path(__file__).parent.parent.joinpath(
'InvenTree/static/tabler-icons/icons.json'
)
with open(tabler_icons_path, 'r') as tabler_icons_file:
tabler_icons = json.load(tabler_icons_file)
icon_packs = [
IconPack(
name='Tabler Icons',
prefix='ti',
fonts={
'woff2': static('tabler-icons/tabler-icons.woff2'),
'woff': static('tabler-icons/tabler-icons.woff'),
'truetype': static('tabler-icons/tabler-icons.ttf'),
},
icons=tabler_icons,
)
]
from plugin import registry
for plugin in registry.with_mixin('icon_pack', active=True):
try:
icon_packs.extend(plugin.icon_packs())
except Exception as e:
logger.warning('Error loading icon pack from plugin %s: %s', plugin, e)
_icon_packs = {pack.prefix: pack for pack in icon_packs}
return _icon_packs
def reload_icon_packs():
"""Reload the icon packs."""
global _icon_packs
_icon_packs = None
get_icon_packs()
def validate_icon(icon: str):
"""Validate an icon string in the format pack:name:variant."""
try:
pack, name, variant = icon.split(':')
except ValueError:
raise ValidationError(
f'Invalid icon format: {icon}, expected: pack:name:variant'
)
packs = get_icon_packs()
if pack not in packs:
raise ValidationError(f'Invalid icon pack: {pack}')
if name not in packs[pack].icons:
raise ValidationError(f'Invalid icon name: {name}')
if variant not in packs[pack].icons[name]['variants']:
raise ValidationError(f'Invalid icon variant: {variant}')
return packs[pack], packs[pack].icons[name], variant

View File

@ -9,12 +9,13 @@ import hmac
import json import json
import logging import logging
import os import os
import sys
import uuid import uuid
from datetime import timedelta, timezone from datetime import timedelta, timezone
from enum import Enum from enum import Enum
from io import BytesIO from io import BytesIO
from secrets import compare_digest from secrets import compare_digest
from typing import Any, Callable, Collection, TypedDict, Union from typing import Any, Callable, TypedDict, Union
from django.apps import apps from django.apps import apps
from django.conf import settings as django_settings from django.conf import settings as django_settings
@ -49,6 +50,7 @@ import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import InvenTree.validators import InvenTree.validators
import order.validators import order.validators
import plugin.base.barcodes.helper
import report.helpers import report.helpers
import users.models import users.models
from InvenTree.sanitizer import sanitize_svg from InvenTree.sanitizer import sanitize_svg
@ -56,6 +58,17 @@ from plugin import registry
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
if sys.version_info >= (3, 11):
from typing import NotRequired
else:
class NotRequired: # pragma: no cover
"""NotRequired type helper is only supported with Python 3.11+."""
def __class_getitem__(cls, item):
"""Return the item."""
return item
class MetaMixin(models.Model): class MetaMixin(models.Model):
"""A base class for InvenTree models to include shared meta fields. """A base class for InvenTree models to include shared meta fields.
@ -1167,7 +1180,7 @@ class InvenTreeSettingsKeyType(SettingsKeyType):
requires_restart: If True, a server restart is required after changing the setting requires_restart: If True, a server restart is required after changing the setting
""" """
requires_restart: bool requires_restart: NotRequired[bool]
class InvenTreeSetting(BaseInvenTreeSetting): class InvenTreeSetting(BaseInvenTreeSetting):
@ -1402,6 +1415,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'BARCODE_GENERATION_PLUGIN': {
'name': _('Barcode Generation Plugin'),
'description': _('Plugin to use for internal barcode data generation'),
'choices': plugin.base.barcodes.helper.barcode_plugins,
'default': 'inventreebarcode',
},
'PART_ENABLE_REVISION': { 'PART_ENABLE_REVISION': {
'name': _('Part Revisions'), 'name': _('Part Revisions'),
'description': _('Enable revision field for Part'), 'description': _('Enable revision field for Part'),
@ -1539,6 +1558,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Part Category Default Icon'), 'name': _('Part Category Default Icon'),
'description': _('Part category default icon (empty means no icon)'), 'description': _('Part category default icon (empty means no icon)'),
'default': '', 'default': '',
'validator': common.validators.validate_icon,
}, },
'PART_PARAMETER_ENFORCE_UNITS': { 'PART_PARAMETER_ENFORCE_UNITS': {
'name': _('Enforce Parameter Units'), 'name': _('Enforce Parameter Units'),
@ -1760,6 +1780,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Stock Location Default Icon'), 'name': _('Stock Location Default Icon'),
'description': _('Stock location default icon (empty means no icon)'), 'description': _('Stock location default icon (empty means no icon)'),
'default': '', 'default': '',
'validator': common.validators.validate_icon,
}, },
'STOCK_SHOW_INSTALLED_ITEMS': { 'STOCK_SHOW_INSTALLED_ITEMS': {
'name': _('Show Installed Stock Items'), 'name': _('Show Installed Stock Items'),
@ -2589,14 +2610,22 @@ class ColorTheme(models.Model):
@classmethod @classmethod
def get_color_themes_choices(cls): def get_color_themes_choices(cls):
"""Get all color themes from static folder.""" """Get all color themes from static folder."""
if not django_settings.STATIC_COLOR_THEMES_DIR.exists(): color_theme_dir = (
logger.error('Theme directory does not exist') django_settings.STATIC_COLOR_THEMES_DIR
if django_settings.STATIC_COLOR_THEMES_DIR.exists()
else django_settings.BASE_DIR.joinpath(
'InvenTree', 'static', 'css', 'color-themes'
)
)
if not color_theme_dir.exists():
logger.error(f'Theme directory "{color_theme_dir}" does not exist')
return [] return []
# Get files list from css/color-themes/ folder # Get files list from css/color-themes/ folder
files_list = [] files_list = []
for file in django_settings.STATIC_COLOR_THEMES_DIR.iterdir(): for file in color_theme_dir.iterdir():
files_list.append([file.stem, file.suffix]) files_list.append([file.stem, file.suffix])
# Get color themes choices (CSS sheets) # Get color themes choices (CSS sheets)

View File

@ -565,3 +565,21 @@ class AttachmentSerializer(InvenTreeModelSerializer):
) )
return super().save() return super().save()
class IconSerializer(serializers.Serializer):
"""Serializer for an icon."""
name = serializers.CharField()
category = serializers.CharField()
tags = serializers.ListField(child=serializers.CharField())
variants = serializers.DictField(child=serializers.CharField())
class IconPackageSerializer(serializers.Serializer):
"""Serializer for a list of icons."""
name = serializers.CharField()
prefix = serializers.CharField()
fonts = serializers.DictField(child=serializers.CharField())
icons = serializers.DictField(child=IconSerializer())

View File

@ -20,6 +20,7 @@ from django.urls import reverse
import PIL import PIL
import common.validators
from common.settings import get_global_setting, set_global_setting from common.settings import get_global_setting, set_global_setting
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin
@ -1199,20 +1200,10 @@ class ColorThemeTest(TestCase):
def test_choices(self): def test_choices(self):
"""Test that default choices are returned.""" """Test that default choices are returned."""
result = ColorTheme.get_color_themes_choices() result = ColorTheme.get_color_themes_choices()
# skip due to directories not being set up
if not result:
return # pragma: no cover
self.assertIn(('default', 'Default'), result) self.assertIn(('default', 'Default'), result)
def test_valid_choice(self): def test_valid_choice(self):
"""Check that is_valid_choice works correctly.""" """Check that is_valid_choice works correctly."""
result = ColorTheme.get_color_themes_choices()
# skip due to directories not being set up
if not result:
return # pragma: no cover
# check wrong reference # check wrong reference
self.assertFalse(ColorTheme.is_valid_choice('abcdd')) self.assertFalse(ColorTheme.is_valid_choice('abcdd'))
@ -1534,3 +1525,44 @@ class ContentTypeAPITest(InvenTreeAPITestCase):
reverse('api-contenttype-detail-modelname', kwargs={'model': None}), reverse('api-contenttype-detail-modelname', kwargs={'model': None}),
expected_code=404, expected_code=404,
) )
class IconAPITest(InvenTreeAPITestCase):
"""Unit tests for the Icons API."""
def test_list(self):
"""Test API list functionality."""
response = self.get(reverse('api-icon-list'), expected_code=200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['prefix'], 'ti')
self.assertEqual(response.data[0]['name'], 'Tabler Icons')
for font_format in ['woff2', 'woff', 'truetype']:
self.assertIn(font_format, response.data[0]['fonts'])
self.assertGreater(len(response.data[0]['icons']), 1000)
class ValidatorsTest(TestCase):
"""Unit tests for the custom validators."""
def test_validate_icon(self):
"""Test the validate_icon function."""
common.validators.validate_icon('')
common.validators.validate_icon(None)
with self.assertRaises(ValidationError):
common.validators.validate_icon('invalid')
with self.assertRaises(ValidationError):
common.validators.validate_icon('my:package:non-existing')
with self.assertRaises(ValidationError):
common.validators.validate_icon(
'ti:my-non-existing-icon:non-existing-variant'
)
with self.assertRaises(ValidationError):
common.validators.validate_icon('ti:package:non-existing-variant')
common.validators.validate_icon('ti:package:outline')

View File

@ -1,10 +1,12 @@
"""Validation helpers for common models.""" """Validation helpers for common models."""
import re import re
from typing import Union
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import common.icons
from common.settings import get_global_setting from common.settings import get_global_setting
@ -103,3 +105,11 @@ def validate_email_domains(setting):
raise ValidationError(_('An empty domain is not allowed.')) raise ValidationError(_('An empty domain is not allowed.'))
if not re.match(r'^@[a-zA-Z0-9\.\-_]+$', domain): if not re.match(r'^@[a-zA-Z0-9\.\-_]+$', domain):
raise ValidationError(_(f'Invalid domain name: {domain}')) raise ValidationError(_(f'Invalid domain name: {domain}'))
def validate_icon(name: Union[str, None]):
"""Validate the provided icon name, and ignore if empty."""
if name == '' or name is None:
return
common.icons.validate_icon(name)

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.11 on 2024-07-16 12:58
import InvenTree.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0070_remove_manufacturerpartattachment_manufacturer_part_and_more'),
]
operations = [
migrations.AddField(
model_name='manufacturerpart',
name='notes',
field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
),
migrations.AddField(
model_name='supplierpart',
name='notes',
field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
),
]

View File

@ -451,6 +451,7 @@ class Address(InvenTree.models.InvenTreeModel):
class ManufacturerPart( class ManufacturerPart(
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeMetadataModel, 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. """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.
@ -474,6 +475,11 @@ class ManufacturerPart(
"""Return the API URL associated with the ManufacturerPart instance.""" """Return the API URL associated with the ManufacturerPart instance."""
return reverse('api-manufacturer-part-list') return reverse('api-manufacturer-part-list')
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'MP'
part = models.ForeignKey( part = models.ForeignKey(
'part.Part', 'part.Part',
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -624,6 +630,7 @@ class SupplierPartManager(models.Manager):
class SupplierPart( class SupplierPart(
InvenTree.models.MetadataMixin, InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
common.models.MetaMixin, common.models.MetaMixin,
InvenTree.models.InvenTreeModel, InvenTree.models.InvenTreeModel,
): ):
@ -676,6 +683,11 @@ class SupplierPart(
"""Return custom API filters for this particular instance.""" """Return custom API filters for this particular instance."""
return {'manufacturer_part': {'part': self.part.pk}} return {'manufacturer_part': {'part': self.part.pk}}
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'SP'
def clean(self): def clean(self):
"""Custom clean action for the SupplierPart model. """Custom clean action for the SupplierPart model.

View File

@ -213,7 +213,7 @@ class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerialize
@register_importer() @register_importer()
class ManufacturerPartSerializer( class ManufacturerPartSerializer(
DataImportExportSerializerMixin, InvenTreeTagModelSerializer DataImportExportSerializerMixin, InvenTreeTagModelSerializer, NotesFieldMixin
): ):
"""Serializer for ManufacturerPart object.""" """Serializer for ManufacturerPart object."""
@ -232,6 +232,7 @@ class ManufacturerPartSerializer(
'MPN', 'MPN',
'link', 'link',
'barcode_hash', 'barcode_hash',
'notes',
'tags', 'tags',
] ]
@ -305,7 +306,7 @@ class ManufacturerPartParameterSerializer(
@register_importer() @register_importer()
class SupplierPartSerializer( class SupplierPartSerializer(
DataImportExportSerializerMixin, InvenTreeTagModelSerializer DataImportExportSerializerMixin, InvenTreeTagModelSerializer, NotesFieldMixin
): ):
"""Serializer for SupplierPart object.""" """Serializer for SupplierPart object."""
@ -340,6 +341,7 @@ class SupplierPartSerializer(
'supplier_detail', 'supplier_detail',
'url', 'url',
'updated', 'updated',
'notes',
'tags', 'tags',
] ]

View File

@ -171,11 +171,40 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
</div> </div>
<div class='panel panel-hidden' id='panel-manufacturer-part-notes'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Manufacturer Part Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
<textarea id='manufacturer-part-notes'></textarea>
</div>
</div>
{% endblock page_content %} {% endblock page_content %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
// Load the "notes" tab
onPanelLoad('manufacturer-part-notes', function() {
setupNotesField(
'manufacturer-part-notes',
'{% url "api-manufacturer-part-detail" part.pk %}',
{
model_type: "manufacturerpart",
model_id: {{ part.pk }},
editable: {% js_bool roles.purchase_order.change %},
}
);
});
onPanelLoad("attachments", function() { onPanelLoad("attachments", function() {
loadAttachmentTable('manufacturerpart', {{ part.pk }}); loadAttachmentTable('manufacturerpart', {{ part.pk }});
}); });

View File

@ -8,3 +8,5 @@
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %} {% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
{% trans "Attachments" as text %} {% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %} {% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label="manufacturer-part-notes" text=text icon="fa-clipboard" %}

View File

@ -264,17 +264,46 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
</div> </div>
<div class='panel panel-hidden' id='panel-supplier-part-notes'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Supplier Part Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
<textarea id='supplier-part-notes'></textarea>
</div>
</div>
{% endblock page_content %} {% endblock page_content %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
// Load the "notes" tab
onPanelLoad('supplier-part-notes', function() {
setupNotesField(
'supplier-part-notes',
'{% url "api-supplier-part-detail" part.pk %}',
{
model_type: "supplierpart",
model_id: {{ part.pk }},
editable: {% js_bool roles.purchase_order.change %},
}
);
});
{% if barcodes %} {% if barcodes %}
$("#show-qr-code").click(function() { $("#show-qr-code").click(function() {
showQRDialog( showQRDialog(
'{% trans "Supplier Part QR Code" escape %}', '{% trans "Supplier Part QR Code" escape %}',
'{"supplierpart": {{ part.pk }} }' '{{ part.barcode }}'
); );
}); });

View File

@ -8,3 +8,5 @@
{% include "sidebar_item.html" with label='purchase-orders' text=text icon="fa-shopping-cart" %} {% include "sidebar_item.html" with label='purchase-orders' text=text icon="fa-shopping-cart" %}
{% trans "Supplier Part Pricing" as text %} {% trans "Supplier Part Pricing" as text %}
{% include "sidebar_item.html" with label='pricing' text=text icon="fa-dollar-sign" %} {% include "sidebar_item.html" with label='pricing' text=text icon="fa-dollar-sign" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label="supplier-part-notes" text=text icon="fa-clipboard" %}

View File

@ -160,7 +160,7 @@ class CompanyTest(InvenTreeAPITestCase):
class ContactTest(InvenTreeAPITestCase): class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models.""" """Tests for the Contact models."""
roles = [] roles = ['purchase_order.view']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -266,7 +266,7 @@ class ContactTest(InvenTreeAPITestCase):
class AddressTest(InvenTreeAPITestCase): class AddressTest(InvenTreeAPITestCase):
"""Test cases for Address API endpoints.""" """Test cases for Address API endpoints."""
roles = [] roles = ['purchase_order.view']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -11,6 +11,8 @@
# Note: Database configuration options can also be specified from environmental variables, # Note: Database configuration options can also be specified from environmental variables,
# with the prefix INVENTREE_DB_ # with the prefix INVENTREE_DB_
# e.g INVENTREE_DB_NAME / INVENTREE_DB_USER / INVENTREE_DB_PASSWORD # e.g INVENTREE_DB_NAME / INVENTREE_DB_USER / INVENTREE_DB_PASSWORD
# Do not change this section if you are using the package - use `inventree config` instead
# TO MAINTAINERS: Do not change database strings
database: database:
# --- Available options: --- # --- Available options: ---
# ENGINE: Database engine. Selection from: # ENGINE: Database engine. Selection from:

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.14 on 2024-07-12 03:35
from django.db import migrations, models
import importer.validators
class Migration(migrations.Migration):
dependencies = [
('importer', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='dataimportsession',
name='field_overrides',
field=models.JSONField(blank=True, null=True, validators=[importer.validators.validate_field_defaults], verbose_name='Field Overrides'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.14 on 2024-07-16 03:04
from django.db import migrations, models
import importer.validators
class Migration(migrations.Migration):
dependencies = [
('importer', '0002_dataimportsession_field_overrides'),
]
operations = [
migrations.AddField(
model_name='dataimportsession',
name='field_filters',
field=models.JSONField(blank=True, null=True, validators=[importer.validators.validate_field_defaults], verbose_name='Field Filters'),
),
]

View File

@ -1,5 +1,6 @@
"""Model definitions for the 'importer' app.""" """Model definitions for the 'importer' app."""
import json
import logging import logging
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -31,7 +32,9 @@ class DataImportSession(models.Model):
data_file: FileField for the data file to import data_file: FileField for the data file to import
status: IntegerField for the status of the import session status: IntegerField for the status of the import session
user: ForeignKey to the User who initiated the import user: ForeignKey to the User who initiated the import
field_defaults: JSONField for field default values field_defaults: JSONField for field default values - provides a backup value for a field
field_overrides: JSONField for field override values - used to force a value for a field
field_filters: JSONField for field filter values - optional field API filters
""" """
@staticmethod @staticmethod
@ -92,6 +95,20 @@ class DataImportSession(models.Model):
validators=[importer.validators.validate_field_defaults], validators=[importer.validators.validate_field_defaults],
) )
field_overrides = models.JSONField(
blank=True,
null=True,
verbose_name=_('Field Overrides'),
validators=[importer.validators.validate_field_defaults],
)
field_filters = models.JSONField(
blank=True,
null=True,
verbose_name=_('Field Filters'),
validators=[importer.validators.validate_field_defaults],
)
@property @property
def field_mapping(self): def field_mapping(self):
"""Construct a dict of field mappings for this import session. """Construct a dict of field mappings for this import session.
@ -132,8 +149,15 @@ class DataImportSession(models.Model):
matched_columns = set() matched_columns = set()
field_overrides = self.field_overrides or {}
# Create a default mapping for each available field in the database # Create a default mapping for each available field in the database
for field, field_def in serializer_fields.items(): for field, field_def in serializer_fields.items():
# If an override value is provided for the field,
# skip creating a mapping for this field
if field in field_overrides:
continue
# Generate a list of possible column names for this field # Generate a list of possible column names for this field
field_options = [ field_options = [
field, field,
@ -181,10 +205,15 @@ class DataImportSession(models.Model):
required_fields = self.required_fields() required_fields = self.required_fields()
field_defaults = self.field_defaults or {} field_defaults = self.field_defaults or {}
field_overrides = self.field_overrides or {}
missing_fields = [] missing_fields = []
for field in required_fields.keys(): for field in required_fields.keys():
# An override value exists
if field in field_overrides:
continue
# A default value exists # A default value exists
if field in field_defaults and field_defaults[field]: if field in field_defaults and field_defaults[field]:
continue continue
@ -265,6 +294,18 @@ class DataImportSession(models.Model):
self.status = DataImportStatusCode.PROCESSING.value self.status = DataImportStatusCode.PROCESSING.value
self.save() self.save()
def check_complete(self) -> bool:
"""Check if the import session is complete."""
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()
return True
@property @property
def row_count(self): def row_count(self):
"""Return the number of rows in the import session.""" """Return the number of rows in the import session."""
@ -467,6 +508,34 @@ class DataImportRow(models.Model):
complete = models.BooleanField(default=False, verbose_name=_('Complete')) complete = models.BooleanField(default=False, verbose_name=_('Complete'))
@property
def default_values(self) -> dict:
"""Return a dict object of the 'default' values for this row."""
defaults = self.session.field_defaults or {}
if type(defaults) is not dict:
try:
defaults = json.loads(str(defaults))
except json.JSONDecodeError:
logger.warning('Failed to parse default values for import row')
defaults = {}
return defaults
@property
def override_values(self) -> dict:
"""Return a dict object of the 'override' values for this row."""
overrides = self.session.field_overrides or {}
if type(overrides) is not dict:
try:
overrides = json.loads(str(overrides))
except json.JSONDecodeError:
logger.warning('Failed to parse override values for import row')
overrides = {}
return overrides
def extract_data( def extract_data(
self, available_fields: dict = None, field_mapping: dict = None, commit=True self, available_fields: dict = None, field_mapping: dict = None, commit=True
): ):
@ -477,14 +546,24 @@ class DataImportRow(models.Model):
if not available_fields: if not available_fields:
available_fields = self.session.available_fields() available_fields = self.session.available_fields()
default_values = self.session.field_defaults or {} overrride_values = self.override_values
default_values = self.default_values
data = {} data = {}
# We have mapped column (file) to field (serializer) already # We have mapped column (file) to field (serializer) already
for field, col in field_mapping.items(): for field, col in field_mapping.items():
# Data override (force value and skip any further checks)
if field in overrride_values:
data[field] = overrride_values[field]
continue
# Default value (if provided)
if field in default_values:
data[field] = default_values[field]
# If this field is *not* mapped to any column, skip # If this field is *not* mapped to any column, skip
if not col: if not col or col not in self.row_data:
continue continue
# Extract field type # Extract field type
@ -516,11 +595,14 @@ class DataImportRow(models.Model):
- If available, we use the "default" values provided by the import session - If available, we use the "default" values provided by the import session
- If available, we use the "override" values provided by the import session - If available, we use the "override" values provided by the import session
""" """
data = self.session.field_defaults or {} data = self.default_values
if self.data: if self.data:
data.update(self.data) data.update(self.data)
# Override values take priority, if present
data.update(self.override_values)
return data return data
def construct_serializer(self): def construct_serializer(self):
@ -568,6 +650,8 @@ class DataImportRow(models.Model):
self.complete = True self.complete = True
self.save() self.save()
self.session.check_complete()
except Exception as e: except Exception as e:
self.errors = {'non_field_errors': str(e)} self.errors = {'non_field_errors': str(e)}
result = False result = False

View File

@ -1,5 +1,7 @@
"""API serializers for the importer app.""" """API serializers for the importer app."""
import json
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -47,6 +49,8 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
'columns', 'columns',
'column_mappings', 'column_mappings',
'field_defaults', 'field_defaults',
'field_overrides',
'field_filters',
'row_count', 'row_count',
'completed_row_count', 'completed_row_count',
] ]
@ -75,6 +79,45 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
user_detail = UserSerializer(source='user', read_only=True, many=False) user_detail = UserSerializer(source='user', read_only=True, many=False)
def validate_field_defaults(self, defaults):
"""De-stringify the field defaults."""
if defaults is None:
return None
if type(defaults) is not dict:
try:
defaults = json.loads(str(defaults))
except:
raise ValidationError(_('Invalid field defaults'))
return defaults
def validate_field_overrides(self, overrides):
"""De-stringify the field overrides."""
if overrides is None:
return None
if type(overrides) is not dict:
try:
overrides = json.loads(str(overrides))
except:
raise ValidationError(_('Invalid field overrides'))
return overrides
def validate_field_filters(self, filters):
"""De-stringify the field filters."""
if filters is None:
return None
if type(filters) is not dict:
try:
filters = json.loads(str(filters))
except:
raise ValidationError(_('Invalid field filters'))
return filters
def create(self, validated_data): def create(self, validated_data):
"""Override create method for this serializer. """Override create method for this serializer.
@ -167,4 +210,7 @@ class DataImportAcceptRowSerializer(serializers.Serializer):
for row in rows: for row in rows:
row.validate(commit=True) row.validate(commit=True)
if session := self.context.get('session', None):
session.check_complete()
return rows return rows

View File

@ -1,6 +1,6 @@
"""Custom validation routines for the 'importer' app.""" """Custom validation routines for the 'importer' app."""
import os import json
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -46,4 +46,8 @@ def validate_field_defaults(value):
return return
if type(value) is not dict: if type(value) is not dict:
# OK if we can parse it as JSON
try:
value = json.loads(value)
except json.JSONDecodeError:
raise ValidationError(_('Value must be a valid dictionary object')) raise ValidationError(_('Value must be a valid dictionary object'))

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