2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-23 05:06:32 +00:00

Remove django-allauth-2fa

Fixes 
This commit is contained in:
Matthias Mair
2024-03-21 23:45:04 +01:00
573 changed files with 275075 additions and 160515 deletions
.devcontainer
.github
.gitignore.pkgr.yml.pre-commit-config.yaml
.vscode
CONTRIBUTING.mdDockerfile
InvenTree
InvenTree
_testfolder
build
common
company
config_template.yaml
generic
states
templating
label
locale
bg
LC_MESSAGES
cs
LC_MESSAGES
da
LC_MESSAGES
de
LC_MESSAGES
el
LC_MESSAGES
en
LC_MESSAGES
es
LC_MESSAGES
es_MX
LC_MESSAGES
fa
LC_MESSAGES
fi
LC_MESSAGES
fr
LC_MESSAGES
he
LC_MESSAGES
hi
LC_MESSAGES
hu
LC_MESSAGES
id
LC_MESSAGES
it
LC_MESSAGES
ja
LC_MESSAGES
ko
LC_MESSAGES
lv
LC_MESSAGES
nl
LC_MESSAGES
no
LC_MESSAGES
pl
LC_MESSAGES
pt
LC_MESSAGES
pt_br
LC_MESSAGES
ru
LC_MESSAGES
sk
LC_MESSAGES
sl
LC_MESSAGES
sr
LC_MESSAGES
sv
LC_MESSAGES
th
LC_MESSAGES
tr
LC_MESSAGES
vi
LC_MESSAGES
zh
LC_MESSAGES
zh_Hans
LC_MESSAGES
zh_hant
LC_MESSAGES
machine
order
part
plugin
report
stock
templates
users
web
README.md
ci
contrib/packager.io
docker.dev.env
docker
docs
package-lock.jsonpackage.jsonreadthedocs.ymlrequirements-dev.txtrequirements.inrequirements.txt
src/frontend
.linguircpackage.json
src
App.tsx
components
contexts
defaults
enums
forms
functions
hooks
locales
main.tsx
pages
router.tsx
states
tables
views
vite.config.tsyarn.lock
tasks.pyyarn.lock

@@ -1,16 +1,11 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.241.1/containers/python-3
{
"name": "InvenTree",
"build": {
"dockerfile": "../Dockerfile",
"context": "..",
"target": "devcontainer",
"args": {
"base_image": "mcr.microsoft.com/vscode/devcontainers/base:alpine-3.18",
"workspace": "${containerWorkspaceFolder}"
}
},
"name": "InvenTree devcontainer",
"dockerComposeFile": "docker-compose.yml",
"service": "inventree",
"overrideCommand": true,
"workspaceFolder": "/home/inventree/",
// Configure tool-specific properties.
"customizations": {
@@ -42,40 +37,27 @@
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [5173, 8000],
"forwardPorts": [5173, 8000, 8080],
"portsAttributes": {
"5173": {
"label": "Vite server"
"label": "Vite Server"
},
"8000": {
"label": "InvenTree server"
"label": "InvenTree Server"
},
"8080": {
"label": "mkdocs server"
}
},
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "./.devcontainer/postCreateCommand.sh ${containerWorkspaceFolder}",
"postCreateCommand": ".devcontainer/postCreateCommand.sh",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"containerUser": "vscode",
"features": {
"git": "os-provided"
},
"remoteEnv": {
// InvenTree config
"INVENTREE_DEBUG": "True",
"INVENTREE_LOG_LEVEL": "INFO",
"INVENTREE_DB_ENGINE": "sqlite3",
"INVENTREE_DB_NAME": "${containerWorkspaceFolder}/dev/database.sqlite3",
"INVENTREE_MEDIA_ROOT": "${containerWorkspaceFolder}/dev/media",
"INVENTREE_STATIC_ROOT": "${containerWorkspaceFolder}/dev/static",
"INVENTREE_BACKUP_DIR": "${containerWorkspaceFolder}/dev/backup",
"INVENTREE_CONFIG_FILE": "${containerWorkspaceFolder}/dev/config.yaml",
"INVENTREE_SECRET_KEY_FILE": "${containerWorkspaceFolder}/dev/secret_key.txt",
"INVENTREE_PLUGINS_ENABLED": "True",
"INVENTREE_PLUGIN_DIR": "${containerWorkspaceFolder}/dev/plugins",
"INVENTREE_PLUGIN_FILE": "${containerWorkspaceFolder}/dev/plugins.txt",
// Python config
"PIP_USER": "no",

@@ -0,0 +1,42 @@
version: "3"
services:
db:
image: postgres:13
restart: unless-stopped
expose:
- 5432/tcp
volumes:
- inventreedatabase:/var/lib/postgresql/data:z
environment:
POSTGRES_DB: inventree
POSTGRES_USER: inventree_user
POSTGRES_PASSWORD: inventree_password
inventree:
build:
context: ..
target: dev
args:
base_image: "mcr.microsoft.com/vscode/devcontainers/base:alpine-3.18"
data_dir: "dev"
volumes:
- ../:/home/inventree:z
environment:
INVENTREE_DEBUG: True
INVENTREE_DB_ENGINE: postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_HOST: db
INVENTREE_DB_USER: inventree_user
INVENTREE_DB_PASSWORD: inventree_password
INVENTREE_PLUGINS_ENABLED: True
INVENTREE_SITE_URL: http://localhost:8000
INVENTREE_CORS_ORIGIN_ALLOW_ALL: True
INVENTREE_PY_ENV: /home/inventree/dev/venv
depends_on:
- db
volumes:
inventreedatabase:

@@ -1,21 +1,19 @@
#!/bin/bash
# Avoiding Dubious Ownership in Dev Containers for setup commands that use git
# Note that the local workspace directory is passed through as the first argument $1
git config --global --add safe.directory $1
# create folders
mkdir -p $1/dev/{commandhistory,plugins}
cd $1
git config --global --add safe.directory /home/inventree
# create venv
python3 -m venv $1/dev/venv
. $1/dev/venv/bin/activate
python3 -m venv /home/inventree/dev/venv --system-site-packages --upgrade-deps
. /home/inventree/dev/venv/bin/activate
# setup InvenTree server
pip install invoke
invoke update
# Run initial InvenTree server setup
invoke update -s
# Configure dev environment
invoke setup-dev
# Install required frontend packages
invoke frontend-install
# remove existing gitconfig created by "Avoiding Dubious Ownership" step

1
.github/FUNDING.yml vendored

@@ -1,4 +1,5 @@
github: inventree
ko_fi: inventree
patreon: inventree
polar: inventree
custom: [paypal.me/inventree]

@@ -49,11 +49,14 @@ runs:
shell: bash
run: |
python3 -m pip install -U pip
pip3 install invoke wheel
pip3 install invoke wheel uv
- name: Set the VIRTUAL_ENV variable for uv to work
run: echo "VIRTUAL_ENV=${Python_ROOT_DIR}" >> $GITHUB_ENV
shell: bash
- name: Install Specific Python Dependencies
if: ${{ inputs.pip-dependency }}
shell: bash
run: pip3 install ${{ inputs.pip-dependency }}
run: uv pip install ${{ inputs.pip-dependency }}
# NPM installs
- name: Install node.js ${{ env.node_version }}
@@ -79,12 +82,12 @@ runs:
- name: Install dev requirements
if: ${{ inputs.dev-install == 'true' ||inputs.install == 'true' }}
shell: bash
run: pip install -r requirements-dev.txt
run: uv pip install -r requirements-dev.txt
- name: Run invoke install
if: ${{ inputs.install == 'true' }}
shell: bash
run: invoke install
run: invoke install --uv
- name: Run invoke update
if: ${{ inputs.update == 'true' }}
shell: bash
run: invoke update
run: invoke update --uv

36
.github/dependabot.yml vendored Normal file

@@ -0,0 +1,36 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
- package-ecosystem: docker
directory: /
schedule:
interval: daily
- package-ecosystem: pip
directory: /docker
schedule:
interval: daily
- package-ecosystem: pip
directory: /docs
schedule:
interval: daily
- package-ecosystem: npm
directory: /
schedule:
interval: daily
- package-ecosystem: pip
directory: /
schedule:
interval: daily
- package-ecosystem: npm
directory: /src/frontend
schedule:
interval: daily

@@ -8,6 +8,9 @@ on:
branches:
- l10
env:
python_version: 3.9
jobs:
check:
@@ -21,22 +24,15 @@ jobs:
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
INVENTREE_BACKUP_DIR: ./backup
python_version: 3.9
steps:
- name: Checkout Code
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Set Up Python ${{ env.python_version }}
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
install: true
apt-dependency: gettext
- name: Test Translations
run: invoke translate
- name: Check Migration Files

@@ -24,9 +24,14 @@ on:
branches:
- 'master'
jobs:
permissions:
contents: read
jobs:
paths-filter:
permissions:
contents: read # for dorny/paths-filter to fetch a list of changed files
pull-requests: read # for dorny/paths-filter to read pull requests
name: Filter
runs-on: ubuntu-latest
@@ -35,7 +40,7 @@ jobs:
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
id: filter
with:
filters: |
@@ -45,12 +50,12 @@ jobs:
- docker-compose.yml
- docker.dev.env
- Dockerfile
- InvenTree/settings.py
- requirements.txt
- tasks.py
# Build the docker image
build:
runs-on: ubuntu-latest
needs: paths-filter
if: needs.paths-filter.outputs.docker == 'true' || github.event_name == 'release' || github.event_name == 'push'
permissions:
@@ -59,7 +64,9 @@ jobs:
id-token: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
python_version: 3.9
python_version: "3.11"
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
steps:
- name: Check out repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
@@ -74,10 +81,17 @@ jobs:
python3 ci/version_check.py
echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV
- name: Test Docker Image
id: test-docker
run: |
docker build . --target production --tag inventree-test
docker run --rm inventree-test invoke --version
docker run --rm inventree-test invoke --list
docker run --rm inventree-test gunicorn --version
docker run --rm inventree-test pg_dump --version
- name: Build Docker Image
# Build the development docker image (using docker-compose.yml)
run: |
docker-compose build --no-cache
run: docker-compose build --no-cache
- name: Update Docker Image
run: |
docker-compose run inventree-dev-server invoke update
@@ -102,6 +116,9 @@ jobs:
docker-compose run inventree-dev-server invoke test --disable-pty
docker-compose run inventree-dev-server invoke test --migrations --disable-pty
docker-compose down
- name: Clean up test folder
run: |
rm -rf InvenTree/_testfolder
- name: Set up QEMU
if: github.event_name != 'pull_request'
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # pin@v3.0.0
@@ -111,8 +128,16 @@ jobs:
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 # pin@v3.1.2
- name: Check if Dockerhub login is required
id: docker_login
run: |
if [ -z "${{ secrets.DOCKER_USERNAME }}" ]; then
echo "skip_dockerhub_login=true" >> $GITHUB_ENV
else
echo "skip_dockerhub_login=false" >> $GITHUB_ENV
fi
- name: Login to Dockerhub
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # pin@v3.0.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
@@ -129,16 +154,16 @@ jobs:
- name: Extract Docker metadata
if: github.event_name != 'pull_request'
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # pin@v5.0.0
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # pin@v5.5.1
with:
images: |
inventree/inventree
ghcr.io/inventree/inventree
ghcr.io/${{ github.repository }}
- name: Build and Push
id: build-and-push
- name: Push Docker Images
id: push-docker
if: github.event_name != 'pull_request'
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # pin@v5.0.0
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # pin@v5.3.0
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -150,9 +175,3 @@ jobs:
build-args: |
commit_hash=${{ env.git_commit_hash }}
commit_date=${{ env.git_commit_date }}
- name: Sign the published image
if: ${{ false }} # github.event_name != 'pull_request'
env:
COSIGN_EXPERIMENTAL: "true"
run: cosign sign ${{ steps.meta.outputs.tags }}@${{ steps.build-and-push.outputs.digest }}

@@ -10,7 +10,7 @@ on:
env:
python_version: 3.9
node_version: 16
node_version: 18
# The OS version must be set per job
server_start_sleep: 60
@@ -20,6 +20,7 @@ env:
INVENTREE_MEDIA_ROOT: ../test_inventree_media
INVENTREE_STATIC_ROOT: ../test_inventree_static
INVENTREE_BACKUP_DIR: ../test_inventree_backup
INVENTREE_SITE_URL: http://localhost:8000
jobs:
paths-filter:
@@ -30,10 +31,11 @@ jobs:
server: ${{ steps.filter.outputs.server }}
migrations: ${{ steps.filter.outputs.migrations }}
frontend: ${{ steps.filter.outputs.frontend }}
api: ${{ steps.filter.outputs.api }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
id: filter
with:
filters: |
@@ -44,6 +46,8 @@ jobs:
migrations:
- '**/migrations/**'
- '.github/workflows**'
api:
- 'InvenTree/InvenTree/api_version.py'
frontend:
- 'src/frontend/**'
@@ -83,7 +87,7 @@ jobs:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Run pre-commit Checks
uses: pre-commit/action@646c83fcd040023954eafda54b4db0192ce70507 # pin@v3.0.0
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # pin@v3.0.1
- name: Check Version
run: |
pip install requests
@@ -105,11 +109,100 @@ jobs:
- name: Check Config
run: |
pip install pyyaml
pip install -r docs/requirements.txt
python docs/ci/check_mkdocs_config.py
- name: Check Links
uses: gaurav-nelson/github-action-markdown-link-check@5c5dfc0ac2e225883c0e5f03a85311ec2830d368 # v1
with:
folder-path: docs
config-file: docs/mlc_config.json
check-modified-files-only: 'yes'
use-quiet-mode: 'yes'
schema:
name: Tests - API Schema Documentation
runs-on: ubuntu-20.04
needs: paths-filter
if: needs.paths-filter.outputs.server == 'true'
env:
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
INVENTREE_ADMIN_USER: testuser
INVENTREE_ADMIN_PASSWORD: testpassword
INVENTREE_ADMIN_EMAIL: test@test.com
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
INVENTREE_PYTHON_TEST_USERNAME: testuser
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
- name: Export API Documentation
run: invoke schema --ignore-warnings --filename InvenTree/schema.yml
- name: Upload schema
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
with:
name: schema.yml
path: InvenTree/schema.yml
- name: Download public schema
if: needs.paths-filter.outputs.api == 'false'
run: |
pip install linkcheckmd requests
python -m linkcheckmd docs --recurse
pip install requests >/dev/null 2>&1
version="$(python3 ci/version_check.py only_version 2>&1)"
echo "Version: $version"
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
echo "URL: $url"
curl -s -o api.yaml $url
echo "Downloaded api.yaml"
- name: Check for differences in API Schema
if: needs.paths-filter.outputs.api == 'false'
run: |
diff --color -u InvenTree/schema.yml api.yaml
diff -u InvenTree/schema.yml api.yaml && echo "no difference in API schema " || exit 2
- name: Check schema - including warnings
run: invoke schema
continue-on-error: true
- name: Extract version for publishing
id: version
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
run: |
pip install requests >/dev/null 2>&1
version="$(python3 ci/version_check.py only_version 2>&1)"
echo "Version: $version"
echo "version=$version" >> "$GITHUB_OUTPUT"
schema-push:
name: Push new schema
runs-on: ubuntu-20.04
needs: [paths-filter, schema]
if: needs.schema.result == 'success' && github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true' && github.repository_owner == 'inventree'
env:
version: ${{ needs.schema.outputs.version }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
repository: inventree/schema
token: ${{ secrets.SCHEMA_PAT }}
- name: Download schema artifact
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: schema.yml
- name: Move schema to correct location
run: |
echo "Version: $version"
mkdir export/${version}
mv schema.yml export/${version}/api.yaml
- uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # v5.0.0
with:
commit_message: "Update API schema for ${version}"
python:
name: Tests - inventree-python
@@ -124,9 +217,10 @@ jobs:
INVENTREE_ADMIN_USER: testuser
INVENTREE_ADMIN_PASSWORD: testpassword
INVENTREE_ADMIN_EMAIL: test@test.com
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
INVENTREE_PYTHON_TEST_SERVER: http://127.0.0.1:12345
INVENTREE_PYTHON_TEST_USERNAME: testuser
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
INVENTREE_SITE_URL: http://127.0.0.1:12345
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
@@ -220,7 +314,7 @@ jobs:
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg2 django-redis>=5.0.0
pip-dependency: psycopg django-redis>=5.0.0
dev-install: true
update: true
- name: Run Tests
@@ -302,7 +396,7 @@ jobs:
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg2
pip-dependency: psycopg
dev-install: true
update: true
- name: Run Tests
@@ -357,6 +451,13 @@ jobs:
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.13.5 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.13.5.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
platform_ui:
name: Tests - Platform UI
runs-on: ubuntu-20.04
@@ -406,7 +507,7 @@ jobs:
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && npm run build
run: cd src/frontend && npm run compile && npm run build
- name: Zip frontend
run: |
cd InvenTree/web/static

@@ -37,12 +37,12 @@ jobs:
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && npm run build
run: cd src/frontend && npm run compile && npm run build
- name: Zip frontend
run: |
cd InvenTree/web/static/web
zip -r ../frontend-build.zip *
- uses: svenstaro/upload-release-action@1beeb572c19a9242f4361f4cee78f8e0d9aec5df # pin@2.7.0
- uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # pin@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: InvenTree/web/static/frontend-build.zip

72
.github/workflows/scorecard.yml vendored Normal file

@@ -0,0 +1,72 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: '32 0 * * 0'
push:
branches: [ "master" ]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: "Checkout code"
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@e38b1902ae4f44df626f11ba0734b14fb91f8f86 # v2.1.2
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: false
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # v3.1.0
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2.2.4
with:
sarif_file: results.sarif

@@ -5,6 +5,9 @@ on:
schedule:
- cron: '24 11 * * *'
permissions:
contents: read
jobs:
stale:

@@ -5,6 +5,10 @@ on:
branches:
- master
env:
python_version: 3.9
node_version: 18
jobs:
build:
@@ -18,24 +22,17 @@ jobs:
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
INVENTREE_BACKUP_DIR: ./backup
INVENTREE_SITE_URL: http://localhost:8000
steps:
- name: Checkout Code
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Set up Python 3.9
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
python-version: 3.9
- name: Set up Node 16
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # pin to v3.8.2
with:
node-version: 16
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install -y gettext
pip3 install invoke
invoke install
install: true
npm: true
apt-dependency: gettext
- name: Make Translations
run: invoke translate
- name: Commit files

17
.gitignore vendored

@@ -39,15 +39,6 @@ local_settings.py
# Files used for testing
inventree-demo-dataset/
inventree-data/
dummy_image.*
_tmp.csv
InvenTree/label.pdf
InvenTree/label.png
InvenTree/part_image_123abc.png
label.pdf
label.png
InvenTree/my_special*
_tests*.txt
# Local static and media file storage (only when running in development mode)
inventree_media
@@ -70,6 +61,7 @@ secret_key.txt
.idea/
*.code-workspace
.bash_history
.DS_Store
# https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
.vscode/*
@@ -107,5 +99,12 @@ InvenTree/plugins/
*.mo
messages.ts
# Generated API schema file
api.yaml
# web frontend (static files)
InvenTree/web/static
# Generated docs files
docs/docs/api/*.yml
docs/docs/api/schema/*.yml

@@ -19,9 +19,9 @@ before:
- contrib/packager.io/before.sh
dependencies:
- curl
- python3.9
- python3.9-venv
- python3.9-dev
- "python3.9 | python3.10 | python3.11"
- "python3.9-venv | python3.10-venv | python3.11-venv"
- "python3.9-dev | python3.10-dev | python3.11-dev"
- python3-pip
- python3-cffi
- python3-brotli
@@ -36,4 +36,3 @@ dependencies:
targets:
ubuntu-20.04: true
debian-11: true
debian-12: true

@@ -5,7 +5,9 @@ exclude: |
InvenTree/InvenTree/static/.*|
InvenTree/locale/.*|
src/frontend/src/locales/.*|
.*/migrations/.*
.*/migrations/.* |
src/frontend/yarn.lock |
yarn.lock
)$
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -16,7 +18,7 @@ repos:
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.11
rev: v0.3.4
hooks:
- id: ruff-format
args: [--preview]
@@ -25,19 +27,19 @@ repos:
--fix,
--preview
]
- repo: https://github.com/jazzband/pip-tools
rev: 7.3.0
- repo: https://github.com/matmair/ruff-pre-commit
rev: fac27ee349cbf0f0d71c1069854bfe371d1c62a1 # uv-0.1.23
hooks:
- id: pip-compile
name: pip-compile requirements-dev.in
args: [requirements-dev.in, -o, requirements-dev.txt]
args: [requirements-dev.in, -o, requirements-dev.txt, --python-version=3.9, --no-strip-extras]
files: ^requirements-dev\.(in|txt)$
- id: pip-compile
name: pip-compile requirements.txt
args: [requirements.in, -o, requirements.txt]
args: [requirements.in, -o, requirements.txt,--python-version=3.9, --no-strip-extras]
files: ^requirements\.(in|txt)$
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
rev: v1.34.1
hooks:
- id: djlint-django
- repo: https://github.com/codespell-project/codespell
@@ -52,7 +54,7 @@ repos:
src/frontend/src/locales/.* |
)$
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.3"
rev: "v4.0.0-alpha.8"
hooks:
- id: prettier
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
@@ -60,7 +62,7 @@ repos:
- "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v8.51.0"
rev: "v9.0.0-beta.2"
hooks:
- id: eslint
additional_dependencies:
@@ -71,3 +73,11 @@ repos:
- "@typescript-eslint/eslint-plugin@latest"
- "@typescript-eslint/parser"
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.2
hooks:
- id: gitleaks
#- repo: https://github.com/jumanjihouse/pre-commit-hooks
# rev: 3.0.0
# hooks:
# - id: shellcheck

9
.vscode/launch.json vendored

@@ -14,13 +14,20 @@
"justMyCode": true
},
{
"name": "Python: Django - 3rd party",
"name": "InvenTree Server - 3rd party",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/InvenTree/manage.py",
"args": ["runserver"],
"django": true,
"justMyCode": false
},
{
"name": "InvenTree Frontend - Vite",
"type": "chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src/frontend"
}
]
}

2
.vscode/tasks.json vendored

@@ -45,7 +45,7 @@
{
"label": "setup-test",
"type": "shell",
"command": "inv setup-test --path dev/inventree-demo-dataset",
"command": "inv setup-test -i --path dev/inventree-demo-dataset",
"problemMatcher": [],
},
{

@@ -1,259 +1,6 @@
### Contributing to InvenTree
Hi there, thank you for your interest in contributing!
Please read the contribution guidelines below, before submitting your first pull request to the InvenTree codebase.
Please read our contribution guidelines, before submitting your first pull request to the InvenTree codebase.
## Quickstart
The following commands will get you quickly configure and run a development server, complete with a demo dataset to work with:
### Bare Metal
```bash
git clone https://github.com/inventree/InvenTree.git && cd InvenTree
python3 -m venv env && source env/bin/activate
pip install invoke && invoke
pip install invoke && invoke setup-dev --tests
```
### Docker
```bash
git clone https://github.com/inventree/InvenTree.git && cd InvenTree
docker compose run inventree-dev-server invoke install
docker compose run inventree-dev-server invoke setup-test --dev
docker compose up -d
```
Read the [InvenTree setup documentation](https://docs.inventree.org/en/latest/start/intro/) for a complete installation reference guide.
### Setup Devtools
Run the following command to set up all toolsets for development.
```bash
invoke setup-dev
```
*We recommend you run this command before starting to contribute. This will install and set up `pre-commit` to run some checks before each commit and help reduce errors.*
## Branches and Versioning
InvenTree roughly follow the [GitLab flow](https://docs.gitlab.com/ee/topics/gitlab_flow.html) branching style, to allow simple management of multiple tagged releases, short-lived branches, and development on the main branch.
### Version Numbering
InvenTree version numbering follows the [semantic versioning](https://semver.org/) specification.
### Master Branch
The HEAD of the "main" or "master" branch of InvenTree represents the current "latest" state of code development.
- All feature branches are merged into master
- All bug fixes are merged into master
**No pushing to master:** New features must be submitted as a pull request from a separate branch (one branch per feature).
### Feature Branches
Feature branches should be branched *from* the *master* branch.
- One major feature per branch / pull request
- Feature pull requests are merged back *into* the master branch
- Features *may* also be merged into a release candidate branch
### Stable Branch
The HEAD of the "stable" branch represents the latest stable release code.
- Versioned releases are merged into the "stable" branch
- Bug fix branches are made *from* the "stable" branch
#### Release Candidate Branches
- Release candidate branches are made from master, and merged into stable.
- RC branches are targeted at a major/minor version e.g. "0.5"
- When a release candidate branch is merged into *stable*, the release is tagged
#### Bugfix Branches
- If a bug is discovered in a tagged release version of InvenTree, a "bugfix" or "hotfix" branch should be made *from* that tagged release
- When approved, the branch is merged back *into* stable, with an incremented PATCH number (e.g. 0.4.1 -> 0.4.2)
- The bugfix *must* also be cherry picked into the *master* branch.
## Environment
### Target version
We are currently targeting:
| Name | Minimum version | Note |
|---|---| --- |
| Python | 3.9 | |
| Django | 3.2 | |
| Node | 18 | Only needed for frontend development |
### Auto creating updates
The following tools can be used to auto-upgrade syntax that was depreciated in new versions:
```bash
pip install pyupgrade
pip install django-upgrade
```
To update the codebase run the following script.
```bash
pyupgrade `find . -name "*.py"`
django-upgrade --target-version 3.2 `find . -name "*.py"`
```
## Credits
If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree/blob/master/docs/docs/credits.md). Please try to do that as timely as possible.
## Migration Files
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate` and commit the migration files before submitting the PR.
*Note: A github action checks for unstaged migration files and will reject the PR if it finds any!*
## Unit Testing
Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage for any new features is insufficient, or the overall code coverage is decreased.
The InvenTree code base makes use of [GitHub actions](https://github.com/features/actions) to run a suite of automated tests against the code base every time a new pull request is received. These actions include (but are not limited to):
- Checking Python and Javascript code against standard style guides
- Running unit test suite
- Automated building and pushing of docker images
- Generating translation files
The various github actions can be found in the `./github/workflows` directory
### Run tests locally
To run test locally, use:
```
invoke test
```
To run only partial tests, for example for a module use:
```
invoke test --runtest order
```
To see all the available options:
```
invoke test --help
```
## Code Style
Code style is automatically checked as part of the project's CI pipeline on GitHub. This means that any pull requests which do not conform to the style guidelines will fail CI checks.
### Backend Code
Backend code (Python) is checked against the [PEP style guidelines](https://peps.python.org/pep-0008/). Please write docstrings for each function and class - we follow the [google doc-style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for python.
### Frontend Code
Frontend code (Javascript) is checked using [eslint](https://eslint.org/). While docstrings are not enforced for front-end code, good code documentation is encouraged!
### Running Checks Locally
If you have followed the setup devtools procedure, then code style checking is performend automatically whenever you commit changes to the code.
### Django templates
Django are checked by [djlint](https://github.com/Riverside-Healthcare/djlint) through pre-commit.
The following rules out of the [default set](https://djlint.com/docs/linter/) are not applied:
```bash
D018: (Django) Internal links should use the { % url ... % } pattern
H006: Img tag should have height and width attributes
H008: Attributes should be double quoted
H021: Inline styles should be avoided
H023: Do not use entity references
H025: Tag seems to be an orphan
H030: Consider adding a meta description
H031: Consider adding meta keywords
T002: Double quotes should be used in tags
```
## Documentation
New features or updates to existing features should be accompanied by user documentation.
## Translations
Any user-facing strings *must* be passed through the translation engine.
- InvenTree code is written in English
- User translatable strings are provided in English as the primary language
- Secondary language translations are provided [via Crowdin](https://crowdin.com/project/inventree)
*Note: Translation files are updated via GitHub actions - you do not need to compile translations files before submitting a pull request!*
### Python Code
For strings exposed via Python code, use the following format:
```python
from django.utils.translation import gettext_lazy as _
user_facing_string = _('This string will be exposed to the translation engine!')
```
### Templated Strings
HTML and javascript files are passed through the django templating engine. Translatable strings are implemented as follows:
```html
{ % load i18n % }
<span>{ % trans "This string will be translated" % } - this string will not!</span>
```
## Github use
### Tags
The tags describe issues and PRs in multiple areas:
| Area | Name | Description |
| --- | --- | --- |
| Triage Labels | | |
| | triage:not-checked | Item was not checked by the core team |
| | triage:not-approved | Item is not green-light by maintainer |
| Type Labels | | |
| | breaking | Indicates a major update or change which breaks compatibility |
| | bug | Identifies a bug which needs to be addressed |
| | dependency | Relates to a project dependency |
| | duplicate | Duplicate of another issue or PR |
| | enhancement | This is an suggested enhancement, extending the functionality of an existing feature |
| | experimental | This is a new *experimental* feature which needs to be enabled manually |
| | feature | This is a new feature, introducing novel functionality |
| | help wanted | Assistance required |
| | invalid | This issue or PR is considered invalid |
| | inactive | Indicates lack of activity |
| | migration | Database migration, requires special attention |
| | question | This is a question |
| | roadmap | This is a roadmap feature with no immediate plans for implementation |
| | security | Relates to a security issue |
| | starter | Good issue for a developer new to the project |
| | wontfix | No work will be done against this issue or PR |
| Feature Labels | | |
| | API | Relates to the API |
| | barcode | Barcode scanning and integration |
| | build | Build orders |
| | importer | Data importing and processing |
| | order | Purchase order and sales orders |
| | part | Parts |
| | plugin | Plugin ecosystem |
| | pricing | Pricing functionality |
| | report | Report generation |
| | stock | Stock item management |
| | user interface | User interface |
| Ecosystem Labels | | |
| | backport | Tags that the issue will be backported to a stable branch as a bug-fix |
| | demo | Relates to the InvenTree demo server or dataset |
| | docker | Docker / docker-compose |
| | CI | CI / unit testing ecosystem |
| | refactor | Refactoring existing code |
| | setup | Relates to the InvenTree setup / installation process |
Refer to our [contribution guidelines](https://docs.inventree.org/en/latest/develop/contributing/) for more information!

@@ -9,25 +9,26 @@
# - Runs InvenTree web server under django development server
# - Monitors source files for any changes, and live-reloads server
ARG base_image=python:3.10-alpine3.18
ARG base_image=python:3.11-alpine3.18
FROM ${base_image} as inventree_base
# Build arguments for this image
ARG commit_tag=""
ARG commit_hash=""
ARG commit_date=""
ARG commit_tag=""
ARG data_dir="data"
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
ENV INVOKE_RUN_SHELL="/bin/ash"
ENV INVENTREE_LOG_LEVEL="WARNING"
ENV INVENTREE_DOCKER="true"
# InvenTree paths
ENV INVENTREE_HOME="/home/inventree"
ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/${data_dir}"
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
ENV INVENTREE_BACKUP_DIR="${INVENTREE_DATA_DIR}/backup"
@@ -61,12 +62,11 @@ RUN apk add --no-cache \
libjpeg libwebp zlib \
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#alpine-3-12
py3-pip py3-pillow py3-cffi py3-brotli pango poppler-utils openldap \
# SQLite support
sqlite \
# PostgreSQL support
postgresql-libs postgresql-client \
# MySQL / MariaDB support
mariadb-connector-c-dev mariadb-client && \
# Postgres client
postgresql13-client \
# MySQL / MariaDB client
mariadb-client mariadb-connector-c \
&& \
# fonts
apk --update --upgrade --no-cache add fontconfig ttf-freefont font-noto terminus-font && fc-cache -f
@@ -90,13 +90,13 @@ RUN if [ `apk --print-arch` = "armv7" ]; then \
COPY tasks.py docker/gunicorn.conf.py docker/init.sh ./
RUN chmod +x init.sh
ENTRYPOINT ["/bin/sh", "./init.sh"]
ENTRYPOINT ["/bin/ash", "./init.sh"]
FROM inventree_base as prebuild
ENV PATH=/root/.local/bin:$PATH
RUN ./install_build_packages.sh --no-cache --virtual .build-deps && \
pip install --user -r base_requirements.txt -r requirements.txt --no-cache-dir && \
pip install --user -r base_requirements.txt -r requirements.txt --no-cache && \
apk --purge del .build-deps
# Frontend builder image:
@@ -141,7 +141,7 @@ EXPOSE 5173
# Install packages required for building python packages
RUN ./install_build_packages.sh
RUN pip install -r base_requirements.txt --no-cache-dir
RUN pip install uv --no-cache-dir && pip install -r base_requirements.txt --no-cache
# Install nodejs / npm / yarn
@@ -164,10 +164,3 @@ ENTRYPOINT ["/bin/ash", "./docker/init.sh"]
# Launch the development server
CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"]
# Image target for devcontainer
FROM dev as devcontainer
ARG workspace="/workspaces/InvenTree"
WORKDIR ${WORKSPACE}

@@ -1,5 +1,7 @@
"""Main JSON interface views."""
import sys
from django.conf import settings
from django.db import transaction
from django.http import JsonResponse
@@ -8,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
@@ -18,6 +21,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI
from InvenTree.permissions import RolePermission
from InvenTree.templatetags.inventree_extras import plugins_info
from part.models import Part
from plugin.serializers import MetadataSerializer
from users.models import ApiToken
@@ -28,11 +32,41 @@ from .version import inventreeApiText
from .views import AjaxView
class VersionViewSerializer(serializers.Serializer):
"""Serializer for a single version."""
class VersionSerializer(serializers.Serializer):
"""Serializer for server version."""
server = serializers.CharField()
api = serializers.IntegerField()
commit_hash = serializers.CharField()
commit_date = serializers.CharField()
commit_branch = serializers.CharField()
python = serializers.CharField()
django = serializers.CharField()
class LinkSerializer(serializers.Serializer):
"""Serializer for all possible links."""
doc = serializers.URLField()
code = serializers.URLField()
credit = serializers.URLField()
app = serializers.URLField()
bug = serializers.URLField()
dev = serializers.BooleanField()
up_to_date = serializers.BooleanField()
version = VersionSerializer()
links = LinkSerializer()
class VersionView(APIView):
"""Simple JSON endpoint for InvenTree version information."""
permission_classes = [permissions.IsAdminUser]
@extend_schema(responses={200: OpenApiResponse(response=VersionViewSerializer)})
def get(self, request, *args, **kwargs):
"""Return information about the InvenTree server."""
return JsonResponse({
@@ -81,6 +115,8 @@ class VersionApiSerializer(serializers.Serializer):
class VersionTextView(ListAPI):
"""Simple JSON endpoint for InvenTree version text."""
serializer_class = VersionSerializer
permission_classes = [permissions.IsAdminUser]
@extend_schema(responses={200: OpenApiResponse(response=VersionApiSerializer)})
@@ -116,40 +152,37 @@ class InfoView(AjaxView):
'worker_running': is_worker_running(),
'worker_pending_tasks': self.worker_pending_tasks(),
'plugins_enabled': settings.PLUGINS_ENABLED,
'plugins_install_disabled': settings.PLUGINS_INSTALL_DISABLED,
'active_plugins': plugins_info(),
'email_configured': is_email_configured(),
'debug_mode': settings.DEBUG,
'docker_mode': settings.DOCKER,
'default_locale': settings.LANGUAGE_CODE,
# Following fields are only available to staff users
'system_health': check_system_health() if is_staff else None,
'database': InvenTree.version.inventreeDatabase() if is_staff else None,
'platform': InvenTree.version.inventreePlatform() if is_staff else None,
'installer': InvenTree.version.inventreeInstaller() if is_staff else None,
'target': InvenTree.version.inventreeTarget() if is_staff else None,
'default_locale': settings.LANGUAGE_CODE,
}
return JsonResponse(data)
def check_auth_header(self, request):
"""Check if user is authenticated via a token in the header."""
# TODO @matmair: remove after refacgtor of Token check is done
headers = request.headers.get(
'Authorization', request.headers.get('authorization')
)
if not headers:
return False
from InvenTree.middleware import get_token_from_request
auth = headers.strip()
if not (auth.lower().startswith('token') and len(auth.split()) == 2):
return False
if token := get_token_from_request(request):
# Does the provided token match a valid user?
try:
token = ApiToken.objects.get(key=token)
# Check if the token is active and the user is a staff member
if token.active and token.user and token.user.is_staff:
return True
except ApiToken.DoesNotExist:
pass
token_key = auth.split()[1]
try:
token = ApiToken.objects.get(key=token_key)
if token.active and token.user and token.user.is_staff:
return True
except ApiToken.DoesNotExist:
pass
return False
@@ -328,7 +361,17 @@ class AttachmentMixin:
attachment.save()
class APISearchView(APIView):
class APISearchViewSerializer(serializers.Serializer):
"""Serializer for the APISearchView."""
search = serializers.CharField()
search_regex = serializers.BooleanField(default=False, required=False)
search_whole = serializers.BooleanField(default=False, required=False)
limit = serializers.IntegerField(default=1, required=False)
offset = serializers.IntegerField(default=0, required=False)
class APISearchView(GenericAPIView):
"""A general-purpose 'search' API endpoint.
Returns hits against a number of different models simultaneously,
@@ -338,6 +381,7 @@ class APISearchView(APIView):
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = APISearchViewSerializer
def get_result_types(self):
"""Construct a list of search types we can return."""
@@ -450,4 +494,7 @@ class MetadataView(RetrieveUpdateAPI):
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance."""
# Detect if we are currently generating the OpenAPI schema
if 'spectacular' in sys.argv:
return MetadataSerializer(Part, *args, **kwargs)
return MetadataSerializer(self.get_model_type(), *args, **kwargs)

@@ -1,11 +1,102 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 162
INVENTREE_API_VERSION = 184
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v184 - 2024-03-17 : https://github.com/inventree/InvenTree/pull/10464
- Add additional fields for tests (start/end datetime, test station)
v183 - 2024-03-14 : https://github.com/inventree/InvenTree/pull/5972
- Adds "category_default_location" annotated field to part serializer
- Adds "part_detail.category_default_location" annotated field to stock item serializer
- Adds "part_detail.category_default_location" annotated field to purchase order line serializer
- Adds "parent_default_location" annotated field to category serializer
v182 - 2024-03-13 : https://github.com/inventree/InvenTree/pull/6714
- Expose ReportSnippet model to the /report/snippet/ API endpoint
- Expose ReportAsset model to the /report/asset/ API endpoint
v181 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6541
- Adds "width" and "height" fields to the LabelTemplate API endpoint
- Adds "page_size" and "landscape" fields to the ReportTemplate API endpoint
v180 - 2024-3-02 : https://github.com/inventree/InvenTree/pull/6463
- Tweaks to API documentation to allow automatic documentation generation
v179 - 2024-03-01 : https://github.com/inventree/InvenTree/pull/6605
- Adds "subcategories" count to PartCategory serializer
- Adds "sublocations" count to StockLocation serializer
- Adds "image" field to PartBrief serializer
- Adds "image" field to CompanyBrief serializer
v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604
- Adds "external_stock" field to the Part API endpoint
- Adds "external_stock" field to the BomItem API endpoint
- Adds "external_stock" field to the BuildLine API endpoint
- Stock quantites represented in the BuildLine API endpoint are now filtered by Build.source_location
v177 - 2024-02-27 : https://github.com/inventree/InvenTree/pull/6581
- Adds "subcategoies" count to PartCategoryTree serializer
- Adds "sublocations" count to StockLocationTree serializer
v176 - 2024-02-26 : https://github.com/inventree/InvenTree/pull/6535
- Adds the field "plugins_install_disabled" to the Server info API endpoint
v175 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6538
- Adds "parts" count to PartParameterTemplate serializer
v174 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6536
- Expose PartCategory filters to the API documentation
- Expose StockLocation filters to the API documentation
v173 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6483
- Adds "merge_items" to the PurchaseOrderLine create API endpoint
- Adds "auto_pricing" to the PurchaseOrderLine create/update API endpoint
v172 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6526
- Adds "enabled" field to the PartTestTemplate API endpoint
- Adds "enabled" filter to the PartTestTemplate list
- Adds "enabled" filter to the StockItemTestResult list
v171 - 2024-02-19 : https://github.com/inventree/InvenTree/pull/6516
- Adds "key" as a filterable parameter to PartTestTemplate list endpoint
v170 -> 2024-02-19 : https://github.com/inventree/InvenTree/pull/6514
- Adds "has_results" filter to the PartTestTemplate list endpoint
v169 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/6430
- Adds 'key' field to PartTestTemplate API endpoint
- Adds annotated 'results' field to PartTestTemplate API endpoint
- Adds 'template' field to StockItemTestResult API endpoint
v168 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/4824
- Adds machine CRUD API endpoints
- Adds machine settings API endpoints
- Adds machine restart API endpoint
- Adds machine types/drivers list API endpoints
- Adds machine registry status API endpoint
- Adds 'required' field to the global Settings API
- Discover sub-sub classes of the StatusCode API
v167 -> 2024-02-07: https://github.com/inventree/InvenTree/pull/6440
- Fixes for OpenAPI schema generation
v166 -> 2024-02-04 : https://github.com/inventree/InvenTree/pull/6400
- Adds package_name to plugin API
- Adds mechanism for uninstalling plugins via the API
v165 -> 2024-01-28 : https://github.com/inventree/InvenTree/pull/6040
- Adds supplier_part.name, part.creation_user, part.required_for_sales_order
v164 -> 2024-01-24 : https://github.com/inventree/InvenTree/pull/6343
- Adds "building" quantity to BuildLine API serializer
v163 -> 2024-01-22 : https://github.com/inventree/InvenTree/pull/6314
- Extends API endpoint to expose auth configuration information for signin pages
v162 -> 2024-01-14 : https://github.com/inventree/InvenTree/pull/6230
- Adds API endpoints to provide information on background tasks

@@ -58,6 +58,7 @@ class InvenTreeConfig(AppConfig):
# Let the background worker check for migrations
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations)
self.update_site_url()
self.collect_notification_methods()
self.collect_state_transition_methods()
@@ -223,6 +224,46 @@ class InvenTreeConfig(AppConfig):
except Exception as e:
logger.exception('Error updating exchange rates: %s (%s)', e, type(e))
def update_site_url(self):
"""Update the site URL setting.
- If a fixed SITE_URL is specified (via configuration), it should override the INVENTREE_BASE_URL setting
- If multi-site support is enabled, update the site URL for the current site
"""
import common.models
if not InvenTree.ready.canAppAccessDatabase():
return
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
return
if settings.SITE_URL:
try:
if (
common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
!= settings.SITE_URL
):
common.models.InvenTreeSetting.set_setting(
'INVENTREE_BASE_URL', settings.SITE_URL
)
logger.info('Updated INVENTREE_SITE_URL to %s', settings.SITE_URL)
except Exception:
pass
# If multi-site support is enabled, update the site URL for the current site
try:
from django.contrib.sites.models import Site
site = Site.objects.get_current()
site.domain = settings.SITE_URL
site.save()
logger.info('Updated current site URL to %s', settings.SITE_URL)
except Exception:
pass
def add_user_on_startup(self):
"""Add a user on startup."""
# stop if checks were already created

@@ -0,0 +1,85 @@
"""Custom backend implementations."""
import datetime
import logging
import time
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from maintenance_mode.backends import AbstractStateBackend
import common.models
logger = logging.getLogger('inventree')
class InvenTreeMaintenanceModeBackend(AbstractStateBackend):
"""Custom backend for managing state of maintenance mode.
Stores a timestamp in the database to determine when maintenance mode will elapse.
"""
SETTING_KEY = '_MAINTENANCE_MODE'
def get_value(self) -> bool:
"""Get the current state of the maintenance mode.
Returns:
bool: True if maintenance mode is active, False otherwise.
"""
try:
setting = common.models.InvenTreeSetting.objects.get(key=self.SETTING_KEY)
value = str(setting.value).strip()
except common.models.InvenTreeSetting.DoesNotExist:
# Database is accessible, but setting is not available - assume False
return False
except (IntegrityError, OperationalError, ProgrammingError):
# Database is inaccessible - assume we are not in maintenance mode
logger.debug('Failed to read maintenance mode state - assuming True')
return True
# Extract timestamp from string
try:
# If the timestamp is in the past, we are now *out* of maintenance mode
timestamp = datetime.datetime.fromisoformat(value)
return timestamp > datetime.datetime.now()
except ValueError:
# If the value is not a valid timestamp, assume maintenance mode is not active
return False
def set_value(self, value: bool, retries: int = 5, minutes: int = 5):
"""Set the state of the maintenance mode.
Instead of simply writing "true" or "false" to the setting,
we write a timestamp to the setting, which is used to determine
when maintenance mode will elapse.
This ensures that we will always *exit* maintenance mode after a certain time period.
"""
logger.debug('Setting maintenance mode state: %s', value)
if value:
# Save as isoformat
timestamp = datetime.datetime.now() + datetime.timedelta(minutes=minutes)
timestamp = timestamp.isoformat()
else:
# Blank timestamp means maintenance mode is not active
timestamp = ''
while retries > 0:
try:
common.models.InvenTreeSetting.set_setting(self.SETTING_KEY, timestamp)
# Read the value back to confirm
if self.get_value() == value:
break
except (IntegrityError, OperationalError, ProgrammingError):
# In the database is locked, then
logger.debug(
'Failed to set maintenance mode state (%s retries left)', retries
)
time.sleep(0.1)
retries -= 1
if retries == 0:
logger.warning('Failed to set maintenance mode state')

@@ -10,6 +10,9 @@ import string
import warnings
from pathlib import Path
from django.core.files.base import ContentFile
from django.core.files.storage import Storage
logger = logging.getLogger('inventree')
CONFIG_DATA = None
CONFIG_LOOKUPS = {}
@@ -69,11 +72,16 @@ def get_base_dir() -> Path:
return Path(__file__).parent.parent.resolve()
def ensure_dir(path: Path) -> None:
def ensure_dir(path: Path, storage=None) -> None:
"""Ensure that a directory exists.
If it does not exist, create it.
"""
if storage and isinstance(storage, Storage):
if not storage.exists(str(path)):
storage.save(str(path / '.empty'), ContentFile(''))
return
if not path.exists():
path.mkdir(parents=True, exist_ok=True)

@@ -72,7 +72,7 @@ def user_roles(request):
roles = {}
for role in RuleSet.RULESET_MODELS.keys():
for role in RuleSet.get_ruleset_models().keys():
permissions = {}
for perm in ['view', 'add', 'change', 'delete']:

@@ -1,6 +1,7 @@
"""Helper functions for converting between units."""
import logging
import re
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@@ -9,7 +10,6 @@ import pint
_unit_registry = None
logger = logging.getLogger('inventree')
@@ -36,7 +36,12 @@ def reload_unit_registry():
_unit_registry = None
reg = pint.UnitRegistry()
reg = pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
# Aliases for temperature units
reg.define('@alias degC = Celsius')
reg.define('@alias degF = Fahrenheit')
reg.define('@alias degK = Kelvin')
# Define some "standard" additional units
reg.define('piece = 1')
@@ -70,6 +75,64 @@ def reload_unit_registry():
return reg
def from_engineering_notation(value):
"""Convert a provided value to 'natural' representation from 'engineering' notation.
Ref: https://en.wikipedia.org/wiki/Engineering_notation
In "engineering notation", the unit (or SI prefix) is often combined with the value,
and replaces the decimal point.
Examples:
- 1K2 -> 1.2K
- 3n05 -> 3.05n
- 8R6 -> 8.6R
And, we should also take into account any provided trailing strings:
- 1K2 ohm -> 1.2K ohm
- 10n005F -> 10.005nF
"""
value = str(value).strip()
pattern = f'(\d+)([a-zA-Z]+)(\d+)(.*)'
if match := re.match(pattern, value):
left, prefix, right, suffix = match.groups()
return f'{left}.{right}{prefix}{suffix}'
return value
def convert_value(value, unit):
"""Attempt to convert a value to a specified unit.
Arguments:
value: The value to convert
unit: The target unit to convert to
Returns:
The converted value (ideally a pint.Quantity value)
Raises:
Exception if the value cannot be converted to the specified unit
"""
ureg = get_unit_registry()
# Convert the provided value to a pint.Quantity object
value = ureg.Quantity(value)
# Convert to the specified unit
if unit:
if is_dimensionless(value):
magnitude = value.to_base_units().magnitude
value = ureg.Quantity(magnitude, unit)
else:
value = value.to(unit)
return value
def convert_physical_value(value: str, unit: str = None, strip_units=True):
"""Validate that the provided value is a valid physical quantity.
@@ -84,44 +147,58 @@ def convert_physical_value(value: str, unit: str = None, strip_units=True):
Returns:
The converted quantity, in the specified units
"""
ureg = get_unit_registry()
# Check that the provided unit is available in the unit registry
if unit:
try:
valid = unit in ureg
except Exception as exc:
valid = False
if not valid:
raise ValidationError(_(f'Invalid unit provided ({unit})'))
original = str(value).strip()
# Ensure that the value is a string
value = str(value).strip() if value else ''
unit = str(unit).strip() if unit else ''
# Handle imperial length measurements
if value.count("'") == 1 and value.endswith("'"):
value = value.replace("'", ' feet')
if value.count('"') == 1 and value.endswith('"'):
value = value.replace('"', ' inches')
# Error on blank values
if not value:
raise ValidationError(_('No value provided'))
# Create a "backup" value which be tried if the first value fails
# e.g. value = "10k" and unit = "ohm" -> "10kohm"
# e.g. value = "10m" and unit = "F" -> "10mF"
# Construct a list of values to "attempt" to convert
attempts = [value]
# Attempt to convert from engineering notation
eng = from_engineering_notation(value)
attempts.append(eng)
# Append the unit, if provided
# These are the "final" attempts to convert the value, and *must* appear after previous attempts
if unit:
backup_value = value + unit
else:
backup_value = None
attempts.append(f'{value}{unit}')
attempts.append(f'{eng}{unit}')
ureg = get_unit_registry()
value = None
try:
value = ureg.Quantity(value)
if unit:
if is_dimensionless(value):
magnitude = value.to_base_units().magnitude
value = ureg.Quantity(magnitude, unit)
else:
value = value.to(unit)
except Exception:
if backup_value:
try:
value = ureg.Quantity(backup_value)
except Exception:
value = None
else:
# Run through the available "attempts", take the first successful result
for attempt in attempts:
try:
value = convert_value(attempt, unit)
break
except Exception as exc:
value = None
pass
if value is None:
if unit:

@@ -53,7 +53,10 @@ def log_error(path, error_name=None, error_info=None, error_data=None):
if error_data:
data = error_data
else:
data = '\n'.join(traceback.format_exception(kind, info, data))
try:
data = '\n'.join(traceback.format_exception(kind, info, data))
except AttributeError:
data = 'No traceback information available'
# Log error to stderr
logger.error(info)

@@ -180,7 +180,12 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str:
return result.group(name)
def format_money(money: Money, decimal_places: int = None, format: str = None) -> str:
def format_money(
money: Money,
decimal_places: int = None,
format: str = None,
include_symbol: bool = True,
) -> str:
"""Format money object according to the currently set local.
Args:
@@ -203,10 +208,12 @@ def format_money(money: Money, decimal_places: int = None, format: str = None) -
if decimal_places is not None:
pattern.frac_prec = (decimal_places, decimal_places)
return pattern.apply(
result = pattern.apply(
money.amount,
locale,
currency=money.currency.code,
currency=money.currency.code if include_symbol else '',
currency_digits=decimal_places is None,
decimal_quantization=decimal_places is not None,
)
return result

@@ -5,7 +5,6 @@ import logging
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group, User
from django.contrib.sites.models import Site
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -19,6 +18,7 @@ from crispy_forms.layout import Field, Layout
from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers
import InvenTree.helpers_model
import InvenTree.sso
from common.models import InvenTreeSetting
from InvenTree.exceptions import log_error
@@ -289,7 +289,8 @@ class CustomUrlMixin:
def get_email_confirmation_url(self, request, emailconfirmation):
"""Custom email confirmation (activation) url."""
url = reverse('account_confirm_email', args=[emailconfirmation.key])
return Site.objects.get_current().domain + url
return InvenTree.helpers_model.construct_absolute_url(url)
class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultAccountAdapter):

@@ -1,5 +1,6 @@
"""Provides helper functions used throughout the InvenTree project."""
import datetime
import hashlib
import io
import json
@@ -8,8 +9,10 @@ import os
import os.path
import re
from decimal import Decimal, InvalidOperation
from typing import TypeVar
from wsgiref.util import FileWrapper
import django.utils.timezone as timezone
from django.conf import settings
from django.contrib.staticfiles.storage import StaticFilesStorage
from django.core.exceptions import FieldError, ValidationError
@@ -17,6 +20,7 @@ from django.core.files.storage import default_storage
from django.http import StreamingHttpResponse
from django.utils.translation import gettext_lazy as _
import pytz
import regex
from bleach import clean
from djmoney.money import Money
@@ -30,16 +34,81 @@ from .settings import MEDIA_URL, STATIC_URL
logger = logging.getLogger('inventree')
def generateTestKey(test_name):
def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
"""Extract an integer out of reference."""
# Default value if we cannot convert to an integer
ref_int = 0
reference = str(reference).strip()
# Ignore empty string
if len(reference) == 0:
return 0
# Look at the start of the string - can it be "integerized"?
result = re.match(r'^(\d+)', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
else:
# Look at the "end" of the string
result = re.search(r'(\d+)$', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
# Ensure that the returned values are within the range that can be stored in an IntegerField
# Note: This will result in large values being "clipped"
if clip is not None:
if ref_int > clip:
ref_int = clip
elif ref_int < -clip:
ref_int = -clip
if not allow_negative and ref_int < 0:
ref_int = abs(ref_int)
return ref_int
def generateTestKey(test_name: str) -> str:
"""Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template.
Tests must be named such that they will have unique keys.
"""
if test_name is None:
test_name = ''
key = test_name.strip().lower()
key = key.replace(' ', '')
def valid_char(char: str):
"""Determine if a particular character is valid for use in a test key."""
if not char.isprintable():
return False
if char.isidentifier():
return True
if char.isalnum():
return True
return False
# Remove any characters that cannot be used to represent a variable
key = re.sub(r'[^a-zA-Z0-9]', '', key)
key = ''.join([c for c in key if valid_char(c)])
# If the key starts with a non-identifier character, prefix with an underscore
if len(key) > 0 and not key[0].isidentifier():
key = '_' + key
return key
@@ -797,6 +866,56 @@ def hash_file(filename: str):
return hashlib.md5(open(filename, 'rb').read()).hexdigest()
def server_timezone() -> str:
"""Return the timezone of the server as a string.
e.g. "UTC" / "Australia/Sydney" etc
"""
return settings.TIME_ZONE
def to_local_time(time, target_tz: str = None):
"""Convert the provided time object to the local timezone.
Arguments:
time: The time / date to convert
target_tz: The desired timezone (string) - defaults to server time
Returns:
A timezone aware datetime object, with the desired timezone
Raises:
TypeError: If the provided time object is not a datetime or date object
"""
if isinstance(time, datetime.datetime):
pass
elif isinstance(time, datetime.date):
time = timezone.datetime(year=time.year, month=time.month, day=time.day)
else:
raise TypeError(
f'Argument must be a datetime or date object (found {type(time)}'
)
# Extract timezone information from the provided time
source_tz = getattr(time, 'tzinfo', None)
if not source_tz:
# Default to UTC if not provided
source_tz = pytz.utc
if not target_tz:
target_tz = server_timezone()
try:
target_tz = pytz.timezone(str(target_tz))
except pytz.UnknownTimeZoneError:
target_tz = pytz.utc
target_time = time.replace(tzinfo=source_tz).astimezone(target_tz)
return target_time
def get_objectreference(
obj, type_ref: str = 'content_type', object_ref: str = 'object_id'
):
@@ -840,7 +959,10 @@ def get_objectreference(
return {'name': str(item), 'model': str(model_cls._meta.verbose_name), **ret}
def inheritors(cls):
Inheritors_T = TypeVar('Inheritors_T')
def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
"""Return all classes that are subclasses from the supplied cls."""
subcls = set()
work = [cls]
@@ -857,3 +979,10 @@ def inheritors(cls):
def is_ajax(request):
"""Check if the current request is an AJAX request."""
return request.headers.get('x-requested-with') == 'XMLHttpRequest'
def pui_url(subpath: str) -> str:
"""Return the URL for a PUI subpath."""
if not subpath.startswith('/'):
subpath = '/' + subpath
return f'/{settings.FRONTEND_URL_BASE}{subpath}'

@@ -0,0 +1,106 @@
"""Provides helper mixins that are used throughout the InvenTree project."""
import inspect
from pathlib import Path
from django.conf import settings
from plugin import registry as plg_registry
class ClassValidationMixin:
"""Mixin to validate class attributes and overrides.
Class attributes:
required_attributes: List of class attributes that need to be defined
required_overrides: List of functions that need override, a nested list mean either one of them needs an override
Example:
```py
class Parent(ClassValidationMixin):
NAME: str
def test(self):
pass
required_attributes = ["NAME"]
required_overrides = [test]
class MyClass(Parent):
pass
myClass = MyClass()
myClass.validate() # raises NotImplementedError
```
"""
required_attributes = []
required_overrides = []
@classmethod
def validate(cls):
"""Validate the class against the required attributes/overrides."""
def attribute_missing(key):
"""Check if attribute is missing."""
return not hasattr(cls, key) or getattr(cls, key) == ''
def override_missing(base_implementation):
"""Check if override is missing."""
if isinstance(base_implementation, list):
return all(override_missing(x) for x in base_implementation)
return base_implementation == getattr(
cls, base_implementation.__name__, None
)
missing_attributes = list(filter(attribute_missing, cls.required_attributes))
missing_overrides = list(filter(override_missing, cls.required_overrides))
errors = []
if len(missing_attributes) > 0:
errors.append(
f"did not provide the following attributes: {', '.join(missing_attributes)}"
)
if len(missing_overrides) > 0:
missing_overrides_list = []
for base_implementation in missing_overrides:
if isinstance(base_implementation, list):
missing_overrides_list.append(
'one of '
+ ' or '.join(attr.__name__ for attr in base_implementation)
)
else:
missing_overrides_list.append(base_implementation.__name__)
errors.append(
f"did not override the required attributes: {', '.join(missing_overrides_list)}"
)
if len(errors) > 0:
raise NotImplementedError(f"'{cls}' " + ' and '.join(errors))
class ClassProviderMixin:
"""Mixin to get metadata about a class itself, e.g. the plugin that provided that class."""
@classmethod
def get_provider_file(cls):
"""File that contains the Class definition."""
return inspect.getfile(cls)
@classmethod
def get_provider_plugin(cls):
"""Plugin that contains the Class definition, otherwise None."""
for plg in plg_registry.plugins.values():
if plg.package_path == cls.__module__:
return plg
@classmethod
def get_is_builtin(cls):
"""Is this Class build in the Inventree source code?"""
try:
Path(cls.get_provider_file()).relative_to(settings.BASE_DIR)
return True
except ValueError:
# Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir
return False

@@ -34,47 +34,59 @@ def getSetting(key, backup_value=None):
return common.models.InvenTreeSetting.get_setting(key, backup_value=backup_value)
def construct_absolute_url(*arg, **kwargs):
def get_base_url(request=None):
"""Return the base URL for the InvenTree server.
The base URL is determined in the following order of decreasing priority:
1. If a request object is provided, use the request URL
2. Multi-site is enabled, and the current site has a valid URL
3. If settings.SITE_URL is set (e.g. in the Django settings), use that
4. If the InvenTree setting INVENTREE_BASE_URL is set, use that
"""
# Check if a request is provided
if request:
return request.build_absolute_uri('/')
# Check if multi-site is enabled
try:
from django.contrib.sites.models import Site
return Site.objects.get_current().domain
except (ImportError, RuntimeError):
pass
# Check if a global site URL is provided
if site_url := getattr(settings, 'SITE_URL', None):
return site_url
# Check if a global InvenTree setting is provided
try:
if site_url := common.models.InvenTreeSetting.get_setting(
'INVENTREE_BASE_URL', create=False, cache=False
):
return site_url
except (ProgrammingError, OperationalError):
pass
# No base URL available
return ''
def construct_absolute_url(*arg, base_url=None, request=None):
"""Construct (or attempt to construct) an absolute URL from a relative URL.
This is useful when (for example) sending an email to a user with a link
to something in the InvenTree web framework.
A URL is constructed in the following order:
1. If settings.SITE_URL is set (e.g. in the Django settings), use that
2. If the InvenTree setting INVENTREE_BASE_URL is set, use that
3. Otherwise, use the current request URL (if available)
Args:
*arg: The relative URL to construct
base_url: The base URL to use for the construction (if not provided, will attempt to determine from settings)
request: The request object to use for the construction (optional)
"""
relative_url = '/'.join(arg)
# If a site URL is provided, use that
site_url = getattr(settings, 'SITE_URL', None)
if not base_url:
base_url = get_base_url(request=request)
if not site_url:
# Otherwise, try to use the InvenTree setting
try:
site_url = common.models.InvenTreeSetting.get_setting(
'INVENTREE_BASE_URL', create=False, cache=False
)
except (ProgrammingError, OperationalError):
pass
if not site_url:
# Otherwise, try to use the current request
request = kwargs.get('request', None)
if request:
site_url = request.build_absolute_uri('/')
if not site_url:
# No site URL available, return the relative URL
return relative_url
return urljoin(site_url, relative_url)
def get_base_url(**kwargs):
"""Return the base URL for the InvenTree server."""
return construct_absolute_url('', **kwargs)
return urljoin(base_url, relative_url)
def download_image_from_url(remote_url, timeout=2.5):
@@ -192,6 +204,7 @@ def render_currency(
currency=None,
min_decimal_places=None,
max_decimal_places=None,
include_symbol=True,
):
"""Render a currency / Money object to a formatted string (e.g. for reports).
@@ -201,6 +214,7 @@ def render_currency(
currency: Optionally convert to the specified currency
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
include_symbol: If True, include the currency symbol in the output
"""
if money in [None, '']:
return '-'
@@ -246,7 +260,9 @@ def render_currency(
decimal_places = max(decimal_places, max_decimal_places)
return format_money(money, decimal_places=decimal_places)
return format_money(
money, decimal_places=decimal_places, include_symbol=include_symbol
)
def getModelsWithMixin(mixin_class) -> list:
@@ -287,6 +303,11 @@ def notify_responsible(
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
exclude (User, optional): User instance that should be excluded. Defaults to None.
"""
import InvenTree.ready
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
return
notify_users(
[instance.responsible], instance, sender, content=content, exclude=exclude
)

@@ -2,12 +2,14 @@
If a new language translation is supported, it must be added here
After adding a new language, run the following command:
python manage.py makemessages -l <language_code> -e html,js,py --no-wrap
where <language_code> is the code for the new language
- where <language_code> is the code for the new language
Additionally, update the following files with the new locale code:
- /src/frontend/.linguirc file
- /src/frontend/src/context/LanguageContext.tsx
- /src/frontend/src/contexts/LanguageContext.tsx
"""
from django.utils.translation import gettext_lazy as _
@@ -30,12 +32,14 @@ LOCALES = [
('it', _('Italian')),
('ja', _('Japanese')),
('ko', _('Korean')),
('lv', _('Latvian')),
('nl', _('Dutch')),
('no', _('Norwegian')),
('pl', _('Polish')),
('pt', _('Portuguese')),
('pt-br', _('Portuguese (Brazilian)')),
('ru', _('Russian')),
('sk', _('Slovak')),
('sl', _('Slovenian')),
('sr', _('Serbian')),
('sv', _('Swedish')),

@@ -2,7 +2,6 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
@@ -10,21 +9,23 @@ from django.utils.translation import gettext_lazy as _
import sesame.utils
from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
import InvenTree.version
def send_simple_login_email(user, link):
"""Send an email with the login link to this user."""
site = Site.objects.get_current()
site_name = InvenTree.version.inventreeInstanceName()
context = {'username': user.username, 'site_name': site.name, 'link': link}
context = {'username': user.username, 'site_name': site_name, 'link': link}
email_plaintext_message = render_to_string(
'InvenTree/user_simple_login.txt', context
)
send_mail(
_(f'[{site.name}] Log in to the app'),
_(f'[{site_name}] Log in to the app'),
email_plaintext_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
@@ -37,7 +38,7 @@ class GetSimpleLoginSerializer(serializers.Serializer):
email = serializers.CharField(label=_('Email'))
class GetSimpleLoginView(APIView):
class GetSimpleLoginView(GenericAPIView):
"""View to send a simple login link."""
permission_classes = ()

@@ -0,0 +1,19 @@
"""Check if there are any pending database migrations, and run them."""
import logging
from django.core.management.base import BaseCommand
from InvenTree.tasks import check_for_migrations
logger = logging.getLogger('inventree')
class Command(BaseCommand):
"""Check if there are any pending database migrations, and run them."""
def handle(self, *args, **kwargs):
"""Check for any pending database migrations."""
logger.info('Checking for pending database migrations')
check_for_migrations(force=True, reload_registry=False)
logger.info('Database migrations complete')

@@ -3,60 +3,73 @@
- This is crucial after importing any fixtures, etc
"""
import logging
from django.core.management.base import BaseCommand
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
logger = logging.getLogger('inventree')
class Command(BaseCommand):
"""Rebuild all database models which leverage the MPTT structure."""
def handle(self, *args, **kwargs):
"""Rebuild all database models which leverage the MPTT structure."""
with maintenance_mode_on():
self.rebuild_models()
set_maintenance_mode(False)
def rebuild_models(self):
"""Rebuild all MPTT models in the database."""
# Part model
try:
print('Rebuilding Part objects')
logger.info('Rebuilding Part objects')
from part.models import Part
Part.objects.rebuild()
except Exception:
print('Error rebuilding Part objects')
logger.info('Error rebuilding Part objects')
# Part category
try:
print('Rebuilding PartCategory objects')
logger.info('Rebuilding PartCategory objects')
from part.models import PartCategory
PartCategory.objects.rebuild()
except Exception:
print('Error rebuilding PartCategory objects')
logger.info('Error rebuilding PartCategory objects')
# StockItem model
try:
print('Rebuilding StockItem objects')
logger.info('Rebuilding StockItem objects')
from stock.models import StockItem
StockItem.objects.rebuild()
except Exception:
print('Error rebuilding StockItem objects')
logger.info('Error rebuilding StockItem objects')
# StockLocation model
try:
print('Rebuilding StockLocation objects')
logger.info('Rebuilding StockLocation objects')
from stock.models import StockLocation
StockLocation.objects.rebuild()
except Exception:
print('Error rebuilding StockLocation objects')
logger.info('Error rebuilding StockLocation objects')
# Build model
try:
print('Rebuilding Build objects')
logger.info('Rebuilding Build objects')
from build.models import Build
Build.objects.rebuild()
except Exception:
print('Error rebuilding Build objects')
logger.info('Error rebuilding Build objects')

@@ -0,0 +1,19 @@
"""Check if there are any pending database migrations, and run them."""
import logging
from django.core.management.base import BaseCommand
from InvenTree.tasks import check_for_migrations
logger = logging.getLogger('inventree')
class Command(BaseCommand):
"""Check if there are any pending database migrations, and run them."""
def handle(self, *args, **kwargs):
"""Check for any pending database migrations."""
logger.info('Checking for pending database migrations')
check_for_migrations(force=True, reload_registry=False)
logger.info('Database migrations complete')

@@ -7,6 +7,7 @@ from rest_framework.fields import empty
from rest_framework.metadata import SimpleMetadata
from rest_framework.utils import model_meta
import common.models
import InvenTree.permissions
import users.models
from InvenTree.helpers import str2bool
@@ -208,7 +209,10 @@ class InvenTreeMetadata(SimpleMetadata):
pk = kwargs[field]
break
if pk is not None:
if issubclass(model_class, common.models.BaseInvenTreeSetting):
instance = model_class.get_setting_object(**kwargs, create=False)
elif pk is not None:
try:
instance = model_class.objects.get(pk=pk)
except (ValueError, model_class.DoesNotExist):

@@ -17,6 +17,23 @@ from users.models import ApiToken
logger = logging.getLogger('inventree')
def get_token_from_request(request):
"""Extract token information from a request object."""
auth_keys = ['Authorization', 'authorization']
token_keys = ['token', 'bearer']
for k in auth_keys:
if auth_header := request.headers.get(k, None):
auth_header = auth_header.strip().lower().split()
if len(auth_header) > 1:
if auth_header[0].strip().lower().replace(':', '') in token_keys:
token = auth_header[1]
return token
return None
class AuthRequiredMiddleware(object):
"""Check for user to be authenticated."""
@@ -24,6 +41,22 @@ class AuthRequiredMiddleware(object):
"""Save response object."""
self.get_response = get_response
def check_token(self, request) -> bool:
"""Check if the user is authenticated via token."""
if token := get_token_from_request(request):
# Does the provided token match a valid user?
try:
token = ApiToken.objects.get(key=token)
if token.active and token.user:
# Provide the user information to the request
request.user = token.user
return True
except ApiToken.DoesNotExist:
logger.warning('Access denied for unknown token %s', token)
return False
def __call__(self, request):
"""Check if user needs to be authenticated and is.
@@ -40,6 +73,7 @@ class AuthRequiredMiddleware(object):
# Is the function exempt from auth requirements?
path_func = resolve(request.path).func
if getattr(path_func, 'auth_exempt', False) is True:
return self.get_response(request)
@@ -69,28 +103,8 @@ class AuthRequiredMiddleware(object):
):
authorized = True
elif (
'Authorization' in request.headers.keys()
or 'authorization' in request.headers.keys()
):
auth = request.headers.get(
'Authorization', request.headers.get('authorization')
).strip()
if auth.lower().startswith('token') and len(auth.split()) == 2:
token_key = auth.split()[1]
# Does the provided token match a valid user?
try:
token = ApiToken.objects.get(key=token_key)
if token.active and token.user:
# Provide the user information to the request
request.user = token.user
authorized = True
except ApiToken.DoesNotExist:
logger.warning('Access denied for unknown token %s', token_key)
elif self.check_token(request):
authorized = True
# No authorization was found for the request
if not authorized:
@@ -105,7 +119,13 @@ class AuthRequiredMiddleware(object):
]
# Do not redirect requests to any of these paths
paths_ignore = ['/api/', '/js/', '/media/', '/static/']
paths_ignore = [
'/api/',
'/auth/',
'/js/',
settings.MEDIA_URL,
settings.STATIC_URL,
]
if path not in urls and not any(
path.startswith(p) for p in paths_ignore

@@ -9,56 +9,6 @@ from InvenTree.fields import InvenTreeNotesField
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
class DiffMixin:
"""Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
def get_db_instance(self):
"""Return the instance of the object saved in the database.
Returns:
object: Instance of the object saved in the database
"""
if self.pk:
try:
return self.__class__.objects.get(pk=self.pk)
except self.__class__.DoesNotExist:
pass
return None
def get_field_deltas(self):
"""Return a dict of field deltas.
Compares the current instance with the instance saved in the database,
and returns a dict of fields which have changed.
Returns:
dict: Dict of field deltas
"""
db_instance = self.get_db_instance()
if db_instance is None:
return {}
deltas = {}
for field in self._meta.fields:
if field.name == 'id':
continue
if getattr(self, field.name) != getattr(db_instance, field.name):
deltas[field.name] = {
'old': getattr(db_instance, field.name),
'new': getattr(self, field.name),
}
return deltas
def has_field_changed(self, field_name):
"""Determine if a particular field has changed."""
return field_name in self.get_field_deltas()
class CleanMixin:
"""Model mixin class which cleans inputs using the Mozilla bleach tools."""

@@ -2,7 +2,6 @@
import logging
import os
import re
from datetime import datetime
from io import BytesIO
@@ -30,18 +29,98 @@ from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree')
def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
class DiffMixin:
"""Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
def get_db_instance(self):
"""Return the instance of the object saved in the database.
Returns:
path to store file, format: '<subdir>/<id>/filename'
Returns:
object: Instance of the object saved in the database
"""
if self.pk:
try:
return self.__class__.objects.get(pk=self.pk)
except self.__class__.DoesNotExist:
pass
return None
def get_field_deltas(self):
"""Return a dict of field deltas.
Compares the current instance with the instance saved in the database,
and returns a dict of fields which have changed.
Returns:
dict: Dict of field deltas
"""
db_instance = self.get_db_instance()
if db_instance is None:
return {}
deltas = {}
for field in self._meta.fields:
if field.name == 'id':
continue
if getattr(self, field.name) != getattr(db_instance, field.name):
deltas[field.name] = {
'old': getattr(db_instance, field.name),
'new': getattr(self, field.name),
}
return deltas
def has_field_changed(self, field_name):
"""Determine if a particular field has changed."""
return field_name in self.get_field_deltas()
class PluginValidationMixin(DiffMixin):
"""Mixin class which exposes the model instance to plugin validation.
Any model class which inherits from this mixin will be exposed to the plugin validation system.
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)
def run_plugin_validation(self):
"""Throw this model against the plugin validation interface."""
from plugin.registry import registry
deltas = self.get_field_deltas()
for plugin in registry.with_mixin('validation'):
try:
if plugin.validate_model_instance(self, deltas=deltas) is True:
return
except ValidationError as exc:
raise exc
except Exception as exc:
# Log the exception to the database
import InvenTree.exceptions
InvenTree.exceptions.log_error(
f'plugins.{plugin.slug}.validate_model_instance'
)
raise ValidationError(_('Error running plugin validation'))
def full_clean(self, *args, **kwargs):
"""Run plugin validation on full model clean.
Note that plugin validation is performed *after* super.full_clean()
"""
super().full_clean(*args, **kwargs)
self.run_plugin_validation()
def save(self, *args, **kwargs):
"""Run plugin validation on model save.
Note that plugin validation is performed *before* super.save()
"""
self.run_plugin_validation()
super().save(*args, **kwargs)
class MetadataMixin(models.Model):
@@ -377,7 +456,7 @@ class ReferenceIndexingMixin(models.Model):
except Exception:
pass
reference_int = extract_int(reference)
reference_int = InvenTree.helpers.extract_int(reference)
if validate:
if reference_int > models.BigIntegerField.MAX_BIGINT:
@@ -388,52 +467,44 @@ class ReferenceIndexingMixin(models.Model):
reference_int = models.BigIntegerField(default=0)
def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
"""Extract an integer out of reference."""
# Default value if we cannot convert to an integer
ref_int = 0
class InvenTreeModel(PluginValidationMixin, models.Model):
"""Base class for InvenTree models, which provides some common functionality.
reference = str(reference).strip()
Includes the following mixins by default:
# Ignore empty string
if len(reference) == 0:
return 0
- PluginValidationMixin: Provides a hook for plugins to validate model instances
"""
# Look at the start of the string - can it be "integerized"?
result = re.match(r'^(\d+)', reference)
class Meta:
"""Metaclass options."""
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
else:
# Look at the "end" of the string
result = re.search(r'(\d+)$', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
# Ensure that the returned values are within the range that can be stored in an IntegerField
# Note: This will result in large values being "clipped"
if clip is not None:
if ref_int > clip:
ref_int = clip
elif ref_int < -clip:
ref_int = -clip
if not allow_negative and ref_int < 0:
ref_int = abs(ref_int)
return ref_int
abstract = True
class InvenTreeAttachment(models.Model):
class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
"""Base class for an InvenTree model which includes a metadata field."""
class Meta:
"""Metaclass options."""
abstract = True
def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
Returns:
path to store file, format: '<subdir>/<id>/filename'
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)
class InvenTreeAttachment(InvenTreeModel):
"""Provides an abstracted class for managing file attachments.
An attachment can be either an uploaded file, or an external URL
@@ -615,7 +686,7 @@ class InvenTreeAttachment(models.Model):
return ''
class InvenTreeTree(MPTTModel):
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
"""Provides an abstracted self-referencing tree model for data categories.
- Each Category has one parent Category, which can be blank (for a top-level Category).

@@ -69,6 +69,10 @@ class RolePermission(permissions.BasePermission):
# The required role may be defined for the view class
if role := getattr(view, 'role_required', None):
# If the role is specified as "role.permission", split it
if '.' in role:
role, permission = role.split('.')
return users.models.check_user_role(user, role, permission)
try:

@@ -10,13 +10,64 @@ def isInTestMode():
def isImportingData():
"""Returns True if the database is currently importing data, e.g. 'loaddata' command is performed."""
return 'loaddata' in sys.argv
"""Returns True if the database is currently importing (or exporting) data, e.g. 'loaddata' command is performed."""
return any((x in sys.argv for x in ['flush', 'loaddata', 'dumpdata']))
def isRunningMigrations():
"""Return True if the database is currently running migrations."""
return any((x in sys.argv for x in ['migrate', 'makemigrations', 'showmigrations']))
return any(
(
x in sys.argv
for x in ['migrate', 'makemigrations', 'showmigrations', 'runmigrations']
)
)
def isRebuildingData():
"""Return true if any of the rebuilding commands are being executed."""
return any(
(
x in sys.argv
for x in ['prerender', 'rebuild_models', 'rebuild_thumbnails', 'rebuild']
)
)
def isRunningBackup():
"""Return true if any of the backup commands are being executed."""
return any(
(
x in sys.argv
for x in [
'backup',
'restore',
'dbbackup',
'dbresotore',
'mediabackup',
'mediarestore',
]
)
)
def isInWorkerThread():
"""Returns True if the current thread is a background worker thread."""
return 'qcluster' in sys.argv
def isInServerThread():
"""Returns True if the current thread is a server thread."""
if isInWorkerThread():
return False
if 'runserver' in sys.argv:
return True
if 'gunicorn' in sys.argv[0]:
return True
return False
def isInMainThread():
@@ -28,7 +79,7 @@ def isInMainThread():
if 'runserver' in sys.argv and '--noreload' not in sys.argv:
return os.environ.get('RUN_MAIN', None) == 'true'
return True
return not isInWorkerThread()
def canAppAccessDatabase(
@@ -39,26 +90,30 @@ def canAppAccessDatabase(
There are some circumstances where we don't want the ready function in apps.py
to touch the database
"""
# Prevent database access if we are running backups
if isRunningBackup():
return False
# Prevent database access if we are importing data
if isImportingData():
return False
# Prevent database access if we are rebuilding data
if isRebuildingData():
return False
# Prevent database access if we are running migrations
if not allow_plugins and isRunningMigrations():
return False
# If any of the following management commands are being executed,
# prevent custom "on load" code from running!
excluded_commands = [
'flush',
'loaddata',
'dumpdata',
'check',
'createsuperuser',
'wait_for_db',
'prerender',
'rebuild_models',
'rebuild_thumbnails',
'makemessages',
'compilemessages',
'backup',
'dbbackup',
'mediabackup',
'restore',
'dbrestore',
'mediarestore',
]
if not allow_shell:
@@ -69,12 +124,7 @@ def canAppAccessDatabase(
excluded_commands.append('test')
if not allow_plugins:
excluded_commands.extend([
'makemigrations',
'showmigrations',
'migrate',
'collectstatic',
])
excluded_commands.extend(['collectstatic'])
for cmd in excluded_commands:
if cmd in sys.argv:

@@ -7,7 +7,6 @@ from decimal import Decimal
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -26,7 +25,10 @@ from taggit.serializers import TaggitSerializer
import common.models as common_models
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers_model import download_image_from_url
class EmptySerializer(serializers.Serializer):
"""Empty serializer for use in testing."""
class InvenTreeMoneySerializer(MoneyField):
@@ -155,8 +157,15 @@ class DependentField(serializers.Field):
# check if the request data contains the dependent fields, otherwise skip getting the child
for f in self.depends_on:
if not data.get(f, None):
return
if data.get(f, None) is None:
if (
self.parent
and (v := getattr(self.parent.fields[f], 'default', None))
is not None
):
data[f] = v
else:
return
# partially validate the data for options requests that set raise_exception while calling .get_child(...)
if raise_exception:
@@ -343,7 +352,12 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
try:
instance.full_clean()
except (ValidationError, DjangoValidationError) as exc:
data = exc.message_dict
if hasattr(exc, 'message_dict'):
data = exc.message_dict
elif hasattr(exc, 'message'):
data = {'non_field_errors': [str(exc.message)]}
else:
data = {'non_field_errors': [str(exc)]}
# Change '__all__' key (django style) to 'non_field_errors' (DRF style)
if '__all__' in data:
@@ -445,19 +459,27 @@ class UserCreateSerializer(ExendedUserSerializer):
def create(self, validated_data):
"""Send an e email to the user after creation."""
from InvenTree.helpers_model import get_base_url
base_url = get_base_url()
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
# Send the user an onboarding email (from current site)
current_site = Site.objects.get_current()
domain = current_site.domain
instance.email_user(
subject=_(f'Welcome to {current_site.name}'),
message=_(
f'Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain}).'
),
message = (
_('Your account has been created.')
+ '\n\n'
+ _('Please use the password reset function to login')
)
if base_url:
message += f'\n\nURL: {base_url}'
# Send the user an onboarding email (from current site)
instance.email_user(subject=_('Welcome to InvenTree'), message=message)
return instance
@@ -844,6 +866,8 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
- Attempt to download the image and store it against this object instance
- Catches and re-throws any errors
"""
from InvenTree.helpers_model import download_image_from_url
if not url:
return

@@ -22,11 +22,12 @@ from django.http import Http404
from django.utils.translation import gettext_lazy as _
import moneyed
import pytz
from dotenv import load_dotenv
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
from InvenTree.ready import isInMainThread
from InvenTree.sentry import default_sentry_dsn, init_sentry
from InvenTree.tracing import setup_instruments, setup_tracing
from InvenTree.version import checkMinPythonVersion, inventreeApiVersion
from . import config, locales
@@ -81,6 +82,10 @@ DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', True)
ENABLE_CLASSIC_FRONTEND = get_boolean_setting(
'INVENTREE_CLASSIC_FRONTEND', 'classic_frontend', True
)
# Disable CUI parts if CUI tests are disabled
if TESTING and '--exclude-tag=cui' in sys.argv:
ENABLE_CLASSIC_FRONTEND = False
ENABLE_PLATFORM_FRONTEND = get_boolean_setting(
'INVENTREE_PLATFORM_FRONTEND', 'platform_frontend', True
)
@@ -121,43 +126,26 @@ STATIC_ROOT = config.get_static_dir()
# The filesystem location for uploaded meadia files
MEDIA_ROOT = config.get_media_dir()
# List of allowed hosts (default = allow all)
ALLOWED_HOSTS = get_setting(
'INVENTREE_ALLOWED_HOSTS',
config_key='allowed_hosts',
default_value=['*'],
typecast=list,
)
# Cross Origin Resource Sharing (CORS) options
# Only allow CORS access to API and media endpoints
CORS_URLS_REGEX = r'^/(api|media|static)/.*$'
# Extract CORS options from configuration file
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(
'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=False
)
CORS_ORIGIN_WHITELIST = get_setting(
'INVENTREE_CORS_ORIGIN_WHITELIST',
config_key='cors.whitelist',
default_value=[],
typecast=list,
)
# Needed for the parts importer, directly impacts the maximum parts that can be uploaded
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000
# Web URL endpoint for served static files
STATIC_URL = '/static/'
# Web URL endpoint for served media files
MEDIA_URL = '/media/'
STATICFILES_DIRS = []
# Translated Template settings
STATICFILES_I18_PREFIX = 'i18n'
STATICFILES_I18_SRC = BASE_DIR.joinpath('templates', 'js', 'translated')
STATICFILES_I18_TRG = BASE_DIR.joinpath('InvenTree', 'static_i18n')
# Create the target directory if it does not exist
if not STATICFILES_I18_TRG.exists():
STATICFILES_I18_TRG.mkdir(parents=True)
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
STATICFILES_I18_TRG = STATICFILES_I18_TRG.joinpath(STATICFILES_I18_PREFIX)
@@ -172,9 +160,6 @@ STATFILES_I18_PROCESSORS = ['InvenTree.context.status_codes']
# Color Themes Directory
STATIC_COLOR_THEMES_DIR = STATIC_ROOT.joinpath('css', 'color-themes').resolve()
# Web URL endpoint for served media files
MEDIA_URL = '/media/'
# Database backup options
# Ref: https://django-dbbackup.readthedocs.io/en/master/configuration.html
DBBACKUP_SEND_EMAIL = False
@@ -214,6 +199,7 @@ INSTALLED_APPS = [
'report.apps.ReportConfig',
'stock.apps.StockConfig',
'users.apps.UsersConfig',
'machine.apps.MachineConfig',
'web',
'generic',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
@@ -221,6 +207,7 @@ INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'user_sessions', # db user sessions
'whitenoise.runserver_nostatic',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
@@ -260,9 +247,10 @@ MIDDLEWARE = CONFIG.get(
'x_forwarded_for.middleware.XForwardedForMiddleware',
'user_sessions.middleware.SessionMiddleware', # db user sessions
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'corsheaders.middleware.CorsMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.middleware.common.CommonMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth
'allauth.account.middleware.AccountMiddleware',
@@ -304,7 +292,10 @@ if LDAP_AUTH:
# get global options from dict and use ldap.OPT_* as keys and values
global_options_dict = get_setting(
'INVENTREE_LDAP_GLOBAL_OPTIONS', 'ldap.global_options', {}, dict
'INVENTREE_LDAP_GLOBAL_OPTIONS',
'ldap.global_options',
default_value=None,
typecast=dict,
)
global_options = {}
for k, v in global_options_dict.items():
@@ -374,24 +365,16 @@ if LDAP_AUTH:
)
AUTH_LDAP_DENY_GROUP = get_setting('INVENTREE_LDAP_DENY_GROUP', 'ldap.deny_group')
AUTH_LDAP_USER_FLAGS_BY_GROUP = get_setting(
'INVENTREE_LDAP_USER_FLAGS_BY_GROUP', 'ldap.user_flags_by_group', {}, dict
'INVENTREE_LDAP_USER_FLAGS_BY_GROUP',
'ldap.user_flags_by_group',
default_value=None,
typecast=dict,
)
AUTH_LDAP_FIND_GROUP_PERMS = True
# Internal IP addresses allowed to see the debug toolbar
INTERNAL_IPS = ['127.0.0.1']
# Internal flag to determine if we are running in docker mode
DOCKER = get_boolean_setting('INVENTREE_DOCKER', default_value=False)
if DOCKER: # pragma: no cover
# Internal IP addresses are different when running under docker
hostname, ___, ips = socket.gethostbyname_ex(socket.gethostname())
INTERNAL_IPS = [ip[: ip.rfind('.')] + '.1' for ip in ips] + [
'127.0.0.1',
'10.0.2.2',
]
# Allow secure http developer server in debug mode
if DEBUG:
INSTALLED_APPS.append('sslserver')
@@ -438,9 +421,9 @@ REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler',
'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
'DEFAULT_AUTHENTICATION_CLASSES': (
'users.authentication.ApiTokenAuthentication',
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
'users.authentication.ApiTokenAuthentication',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
@@ -479,18 +462,6 @@ if USE_JWT:
INSTALLED_APPS.append('rest_framework_simplejwt')
# WSGI default setting
SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'},
'EXTERNAL_DOCS': {
'docs': 'https://docs.inventree.org',
'web': 'https://inventree.org',
},
'VERSION': inventreeApiVersion(),
'SERVE_INCLUDE_SCHEMA': False,
}
WSGI_APPLICATION = 'InvenTree.wsgi.application'
"""
@@ -504,7 +475,7 @@ Configure the database backend based on the user-specified values.
logger.debug('Configuring database backend:')
# Extract database configuration from the config.yaml file
db_config = CONFIG.get('database', {})
db_config = CONFIG.get('database', None)
if not db_config:
db_config = {}
@@ -580,14 +551,14 @@ Ref: https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-OPTIONS
# connecting to the database server (such as a replica failover) don't sit and
# wait for possibly an hour or more, just tell the client something went wrong
# and let the client retry when they want to.
db_options = db_config.get('OPTIONS', db_config.get('options', {}))
db_options = db_config.get('OPTIONS', db_config.get('options', None))
if db_options is None:
db_options = {}
# Specific options for postgres backend
if 'postgres' in db_engine: # pragma: no cover
from psycopg2.extensions import (
ISOLATION_LEVEL_READ_COMMITTED,
ISOLATION_LEVEL_SERIALIZABLE,
)
from django.db.backends.postgresql.psycopg_any import IsolationLevel
# Connection timeout
if 'connect_timeout' not in db_options:
@@ -653,9 +624,9 @@ if 'postgres' in db_engine: # pragma: no cover
'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False
)
db_options['isolation_level'] = (
ISOLATION_LEVEL_SERIALIZABLE
IsolationLevel.SERIALIZABLE
if serializable
else ISOLATION_LEVEL_READ_COMMITTED
else IsolationLevel.READ_COMMITTED
)
# Specific options for MySql / MariaDB backend
@@ -732,7 +703,10 @@ if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover
TRACING_ENABLED = get_boolean_setting(
'INVENTREE_TRACING_ENABLED', 'tracing.enabled', False
)
if TRACING_ENABLED: # pragma: no cover
from InvenTree.tracing import setup_instruments, setup_tracing
_t_endpoint = get_setting('INVENTREE_TRACING_ENDPOINT', 'tracing.endpoint', None)
_t_headers = get_setting('INVENTREE_TRACING_HEADERS', 'tracing.headers', None, dict)
@@ -743,7 +717,10 @@ if TRACING_ENABLED: # pragma: no cover
logger.info('OpenTelemetry tracing enabled')
_t_resources = get_setting(
'INVENTREE_TRACING_RESOURCES', 'tracing.resources', {}, dict
'INVENTREE_TRACING_RESOURCES',
'tracing.resources',
default_value=None,
typecast=dict,
)
cstm_tags = {'inventree.env.' + k: v for k, v in inventree_tags.items()}
tracing_resources = {**cstm_tags, **_t_resources}
@@ -755,7 +732,12 @@ if TRACING_ENABLED: # pragma: no cover
console=get_boolean_setting(
'INVENTREE_TRACING_CONSOLE', 'tracing.console', False
),
auth=get_setting('INVENTREE_TRACING_AUTH', 'tracing.auth', {}),
auth=get_setting(
'INVENTREE_TRACING_AUTH',
'tracing.auth',
default_value=None,
typecast=dict,
),
is_http=get_setting('INVENTREE_TRACING_IS_HTTP', 'tracing.is_http', True),
append_http=get_boolean_setting(
'INVENTREE_TRACING_APPEND_HTTP', 'tracing.append_http', True
@@ -814,7 +796,7 @@ Q_CLUSTER = {
get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4)
),
'timeout': _q_worker_timeout,
'retry': min(120, _q_worker_timeout + 30),
'retry': max(120, _q_worker_timeout + 30),
'max_attempts': int(
get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5)
),
@@ -841,7 +823,8 @@ SESSION_ENGINE = 'user_sessions.backends.db'
LOGOUT_REDIRECT_URL = get_setting(
'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index'
)
SILENCED_SYSTEM_CHECKS = ['admin.E410']
SILENCED_SYSTEM_CHECKS = ['admin.E410', 'templates.E003', 'templates.W003']
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
@@ -951,9 +934,13 @@ LOCALE_PATHS = (BASE_DIR.joinpath('locale/'),)
TIME_ZONE = get_setting('INVENTREE_TIMEZONE', 'timezone', 'UTC')
USE_I18N = True
# Check that the timezone is valid
try:
pytz.timezone(TIME_ZONE)
except pytz.exceptions.UnknownTimeZoneError: # pragma: no cover
raise ValueError(f"Specified timezone '{TIME_ZONE}' is not valid")
USE_L10N = True
USE_I18N = True
# Do not use native timezone support in "test" mode
# It generates a *lot* of cruft in the logs
@@ -968,13 +955,151 @@ CRISPY_TEMPLATE_PACK = 'bootstrap4'
# Use database transactions when importing / exporting data
IMPORT_EXPORT_USE_TRANSACTIONS = True
SITE_ID = 1
# Site URL can be specified statically, or via a run-time setting
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
if SITE_URL:
logger.info('Using Site URL: %s', SITE_URL)
# Check that the site URL is valid
validator = URLValidator()
validator(SITE_URL)
# Enable or disable multi-site framework
SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
# If a SITE_ID is specified
SITE_ID = get_setting('INVENTREE_SITE_ID', 'site_id', 1 if SITE_MULTI else None)
# Load the allauth social backends
SOCIAL_BACKENDS = get_setting(
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
)
if not SITE_MULTI:
INSTALLED_APPS.remove('django.contrib.sites')
# List of allowed hosts (default = allow all)
# Ref: https://docs.djangoproject.com/en/4.2/ref/settings/#allowed-hosts
ALLOWED_HOSTS = get_setting(
'INVENTREE_ALLOWED_HOSTS',
config_key='allowed_hosts',
default_value=[],
typecast=list,
)
if SITE_URL and SITE_URL not in ALLOWED_HOSTS:
ALLOWED_HOSTS.append(SITE_URL)
if not ALLOWED_HOSTS:
if DEBUG:
logger.info(
'No ALLOWED_HOSTS specified. Defaulting to ["*"] for debug mode. This is not recommended for production use'
)
ALLOWED_HOSTS = ['*']
elif not TESTING:
logger.error(
'No ALLOWED_HOSTS specified. Please provide a list of allowed hosts, or specify INVENTREE_SITE_URL'
)
# Server cannot run without ALLOWED_HOSTS
if isInMainThread():
sys.exit(-1)
# Ensure that the ALLOWED_HOSTS do not contain any scheme info
for i, host in enumerate(ALLOWED_HOSTS):
if '://' in host:
ALLOWED_HOSTS[i] = host.split('://')[1]
# List of trusted origins for unsafe requests
# Ref: https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins
CSRF_TRUSTED_ORIGINS = get_setting(
'INVENTREE_TRUSTED_ORIGINS',
config_key='trusted_origins',
default_value=[],
typecast=list,
)
# If a list of trusted is not specified, but a site URL has been specified, use that
if SITE_URL and SITE_URL not in CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS.append(SITE_URL)
if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
if DEBUG:
logger.warning(
'No CSRF_TRUSTED_ORIGINS specified. Defaulting to http://* for debug mode. This is not recommended for production use'
)
CSRF_TRUSTED_ORIGINS = ['http://*']
elif isInMainThread():
# Server thread cannot run without CSRF_TRUSTED_ORIGINS
logger.error(
'No CSRF_TRUSTED_ORIGINS specified. Please provide a list of trusted origins, or specify INVENTREE_SITE_URL'
)
sys.exit(-1)
USE_X_FORWARDED_HOST = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_HOST',
config_key='use_x_forwarded_host',
default_value=False,
)
USE_X_FORWARDED_PORT = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_PORT',
config_key='use_x_forwarded_port',
default_value=False,
)
# Cross Origin Resource Sharing (CORS) options
# Refer to the django-cors-headers documentation for more information
# Ref: https://github.com/adamchainz/django-cors-headers
# Extract CORS options from configuration file
CORS_ALLOW_ALL_ORIGINS = get_boolean_setting(
'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=DEBUG
)
CORS_ALLOW_CREDENTIALS = get_boolean_setting(
'INVENTREE_CORS_ALLOW_CREDENTIALS',
config_key='cors.allow_credentials',
default_value=True,
)
# Only allow CORS access to the following URL endpoints
CORS_URLS_REGEX = r'^/(api|auth|media|static)/.*$'
CORS_ALLOWED_ORIGINS = get_setting(
'INVENTREE_CORS_ORIGIN_WHITELIST',
config_key='cors.whitelist',
default_value=[],
typecast=list,
)
# If no CORS origins are specified, but a site URL has been specified, use that
if SITE_URL and SITE_URL not in CORS_ALLOWED_ORIGINS:
CORS_ALLOWED_ORIGINS.append(SITE_URL)
CORS_ALLOWED_ORIGIN_REGEXES = get_setting(
'INVENTREE_CORS_ORIGIN_REGEX',
config_key='cors.regex',
default_value=[],
typecast=list,
)
# In debug mode allow CORS requests from localhost
# This allows connection from the frontend development server
if DEBUG:
CORS_ALLOWED_ORIGIN_REGEXES.append(r'^http://localhost:\d+$')
if CORS_ALLOW_ALL_ORIGINS:
logger.info('CORS: All origins allowed')
else:
if CORS_ALLOWED_ORIGINS:
logger.info('CORS: Whitelisted origins: %s', CORS_ALLOWED_ORIGINS)
if CORS_ALLOWED_ORIGIN_REGEXES:
logger.info('CORS: Whitelisted origin regexes: %s', CORS_ALLOWED_ORIGIN_REGEXES)
for app in SOCIAL_BACKENDS:
# Ensure that the app starts with 'allauth.socialaccount.providers'
social_prefix = 'allauth.socialaccount.providers.'
@@ -990,6 +1115,11 @@ SOCIALACCOUNT_PROVIDERS = get_setting(
SOCIALACCOUNT_STORE_TOKENS = True
# Explicitly set empty URL prefix for OIDC
# The SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX setting was introduced in v0.60.0
# Ref: https://github.com/pennersr/django-allauth/blob/0.60.0/ChangeLog.rst#backwards-incompatible-changes
SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX = ''
# settings for allauth
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting(
'INVENTREE_LOGIN_CONFIRM_DAYS', 'login_confirm_days', 3, typecast=int
@@ -1002,6 +1132,9 @@ ACCOUNT_DEFAULT_HTTP_PROTOCOL = get_setting(
)
ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
ACCOUNT_PREVENT_ENUMERATION = True
ACCOUNT_EMAIL_SUBJECT_PREFIX = EMAIL_SUBJECT_PREFIX
# 2FA
REMOVE_SUCCESS_URL = 'settings'
# override forms / adapters
ACCOUNT_FORMS = {
@@ -1056,13 +1189,16 @@ MARKDOWNIFY = {
IGNORED_ERRORS = [Http404, django.core.exceptions.PermissionDenied]
# Maintenance mode
MAINTENANCE_MODE_RETRY_AFTER = 60
MAINTENANCE_MODE_STATE_BACKEND = 'maintenance_mode.backends.StaticStorageBackend'
MAINTENANCE_MODE_RETRY_AFTER = 10
MAINTENANCE_MODE_STATE_BACKEND = 'InvenTree.backends.InvenTreeMaintenanceModeBackend'
# Are plugins enabled?
PLUGINS_ENABLED = get_boolean_setting(
'INVENTREE_PLUGINS_ENABLED', 'plugins_enabled', False
)
PLUGINS_INSTALL_DISABLED = get_boolean_setting(
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
)
PLUGIN_FILE = config.get_plugin_file()
@@ -1079,15 +1215,8 @@ PLUGIN_RETRY = get_setting(
) # How often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# Site URL can be specified statically, or via a run-time setting
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
if SITE_URL:
logger.info('Site URL: %s', SITE_URL)
# Check that the site URL is valid
validator = URLValidator()
validator(SITE_URL)
# Flag to allow table events during testing
TESTING_TABLE_EVENTS = False
# User interface customization values
CUSTOM_LOGO = get_custom_file(
@@ -1097,7 +1226,9 @@ CUSTOM_SPLASH = get_custom_file(
'INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash'
)
CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {})
CUSTOMIZE = get_setting(
'INVENTREE_CUSTOMIZE', 'customize', default_value=None, typecast=dict
)
# Load settings for the frontend interface
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
@@ -1131,5 +1262,24 @@ if CUSTOM_FLAGS:
# Magic login django-sesame
SESAME_MAX_AGE = 300
# LOGIN_REDIRECT_URL = f"/{FRONTEND_URL_BASE}/logged-in/"
LOGIN_REDIRECT_URL = '/index/'
LOGIN_REDIRECT_URL = '/api/auth/login-redirect/'
# Configuratino for API schema generation
SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
'LICENSE': {
'name': 'MIT',
'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE',
},
'EXTERNAL_DOCS': {
'description': 'More information about InvenTree in the official docs',
'url': 'https://docs.inventree.org',
},
'VERSION': str(inventreeApiVersion()),
'SERVE_INCLUDE_SCHEMA': False,
'SCHEMA_PATH_PREFIX': '/api/',
}
if SITE_URL and not TESTING:
SPECTACULAR_SETTINGS['SERVERS'] = [{'url': SITE_URL}]

@@ -9,6 +9,7 @@ from allauth.account.models import EmailAddress
from allauth.socialaccount import providers
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import serializers
from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
@@ -16,7 +17,7 @@ from rest_framework.response import Response
import InvenTree.sso
from common.models import InvenTreeSetting
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer
logger = logging.getLogger('inventree')
@@ -112,11 +113,36 @@ for name, provider in providers.registry.provider_map.items():
social_auth_urlpatterns += provider_urlpatterns
class SocialProviderListResponseSerializer(serializers.Serializer):
"""Serializer for the SocialProviderListView."""
class SocialProvider(serializers.Serializer):
"""Serializer for the SocialProviderListResponseSerializer."""
id = serializers.CharField()
name = serializers.CharField()
configured = serializers.BooleanField()
login = serializers.URLField()
connect = serializers.URLField()
display_name = serializers.CharField()
sso_enabled = serializers.BooleanField()
sso_registration = serializers.BooleanField()
mfa_required = serializers.BooleanField()
providers = SocialProvider(many=True)
registration_enabled = serializers.BooleanField()
password_forgotten_enabled = serializers.BooleanField()
class SocialProviderListView(ListAPI):
"""List of available social providers."""
permission_classes = (AllowAny,)
serializer_class = EmptySerializer
@extend_schema(
responses={200: OpenApiResponse(response=SocialProviderListResponseSerializer)}
)
def get(self, request, *args, **kwargs):
"""Get the list of providers."""
provider_list = []
@@ -153,6 +179,10 @@ class SocialProviderListView(ListAPI):
'sso_registration': InvenTree.sso.registration_enabled(),
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
'providers': provider_list,
'registration_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_REG'),
'password_forgotten_enabled': InvenTreeSetting.get_setting(
'LOGIN_ENABLE_PWD_FORGOT'
),
}
return Response(data)

@@ -9,7 +9,7 @@ import time
import warnings
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Callable, List
from typing import Callable
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
@@ -180,6 +180,8 @@ def offload_task(
Returns:
bool: True if the task was offloaded (or ran), False otherwise
"""
from InvenTree.exceptions import log_error
try:
import importlib
@@ -213,6 +215,7 @@ def offload_task(
return False
except Exception as exc:
raise_warning(f"WARNING: '{taskname}' not offloaded due to {str(exc)}")
log_error('InvenTree.offload_task')
return False
else:
if callable(taskname):
@@ -233,6 +236,7 @@ def offload_task(
try:
_mod = importlib.import_module(app_mod)
except ModuleNotFoundError:
log_error('InvenTree.offload_task')
raise_warning(
f"WARNING: '{taskname}' not started - No module named '{app_mod}'"
)
@@ -249,6 +253,7 @@ def offload_task(
if not _func:
_func = eval(func) # pragma: no cover
except NameError:
log_error('InvenTree.offload_task')
raise_warning(
f"WARNING: '{taskname}' not started - No function named '{func}'"
)
@@ -258,6 +263,7 @@ def offload_task(
try:
_func(*args, **kwargs)
except Exception as exc:
log_error('InvenTree.offload_task')
raise_warning(f"WARNING: '{taskname}' not started due to {str(exc)}")
return False
@@ -291,7 +297,7 @@ class ScheduledTask:
class TaskRegister:
"""Registry for periodic tasks."""
task_list: List[ScheduledTask] = []
task_list: list[ScheduledTask] = []
def register(self, task, schedule, minutes: int = None):
"""Register a task with the que."""
@@ -644,7 +650,7 @@ def get_migration_plan():
@scheduled_task(ScheduledTask.DAILY)
def check_for_migrations():
def check_for_migrations(force: bool = False, reload_registry: bool = True):
"""Checks if migrations are needed.
If the setting auto_update is enabled we will start updating.
@@ -659,8 +665,9 @@ def check_for_migrations():
logger.info('Checking for pending database migrations')
# Force plugin registry reload
registry.check_reload()
if reload_registry:
# Force plugin registry reload
registry.check_reload()
plan = get_migration_plan()
@@ -674,7 +681,7 @@ def check_for_migrations():
set_pending_migrations(n)
# Test if auto-updates are enabled
if not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
if not force and not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
logger.info('Auto-update is disabled - skipping migrations')
return
@@ -706,6 +713,7 @@ def check_for_migrations():
set_maintenance_mode(False)
logger.info('Manually released maintenance mode')
# We should be current now - triggering full reload to make sure all models
# are loaded fully in their new state.
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
if reload_registry:
# We should be current now - triggering full reload to make sure all models
# are loaded fully in their new state.
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)

@@ -52,7 +52,18 @@ class CustomTranslateNode(TranslateNode):
# Escape any quotes contained in the string, if the request is for a javascript file
request = context.get('request', None)
if self.escape or (request and request.path.endswith('.js')):
template = getattr(context, 'template_name', None)
request = context.get('request', None)
escape = self.escape
if template and str(template).endswith('.js'):
escape = True
if request and str(request.path).endswith('.js'):
escape = True
if escape:
result = result.replace("'", r'\'')
result = result.replace('"', r'\"')

@@ -155,6 +155,12 @@ def plugins_enabled(*args, **kwargs):
return djangosettings.PLUGINS_ENABLED
@register.simple_tag()
def plugins_install_disabled(*args, **kwargs):
"""Return True if plugin install is disabled for the server instance."""
return djangosettings.PLUGINS_INSTALL_DISABLED
@register.simple_tag()
def plugins_info(*args, **kwargs):
"""Return information about activated plugins."""
@@ -334,7 +340,10 @@ def setting_object(key, *args, **kwargs):
plg = kwargs['plugin']
if issubclass(plg.__class__, InvenTreePlugin):
plg = plg.plugin_config()
try:
plg = plg.plugin_config()
except plugin.models.PluginConfig.DoesNotExist:
return None
return plugin.models.PluginSetting.get_setting_object(
key, plugin=plg, cache=cache
@@ -418,7 +427,7 @@ def progress_bar(val, max_val, *args, **kwargs):
style_tags.append(f'max-width: {max_width};')
html = f"""
<div id='{item_id}' class='progress' style='{" ".join(style_tags)}'>
<div id='{item_id}' class='progress' style='{' '.join(style_tags)}'>
<div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div>
<div class='progress-value'>{val} / {max_val}</div>
</div>
@@ -503,17 +512,6 @@ def keyvalue(dict, key):
return dict.get(key)
@register.simple_tag()
def call_method(obj, method_name, *args):
"""Enables calling model methods / functions from templates with arguments.
Usage:
{% call_method model_object 'fnc_name' argument1 %}
"""
method = getattr(obj, method_name)
return method(*args)
@register.simple_tag()
def authorized_owners(group):
"""Return authorized owners."""

@@ -25,7 +25,7 @@ class HTMLAPITests(InvenTreeTestCase):
url = reverse('api-part-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
response = self.client.get(url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
def test_build_api(self):
@@ -33,7 +33,7 @@ class HTMLAPITests(InvenTreeTestCase):
url = reverse('api-build-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
response = self.client.get(url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
def test_stock_api(self):
@@ -41,7 +41,7 @@ class HTMLAPITests(InvenTreeTestCase):
url = reverse('api-stock-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
response = self.client.get(url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
def test_company_list(self):
@@ -49,7 +49,7 @@ class HTMLAPITests(InvenTreeTestCase):
url = reverse('api-company-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
response = self.client.get(url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
def test_not_found(self):

@@ -18,6 +18,11 @@ class ApiVersionTests(InvenTreeAPITestCase):
self.assertEqual(len(data), 10)
response = self.client.get(reverse('api-version'), format='json').json()
self.assertIn('version', response)
self.assertIn('dev', response)
self.assertIn('up_to_date', response)
def test_inventree_api_text(self):
"""Test that the inventreeApiText function works expected."""
# Normal run

@@ -2,6 +2,7 @@
from django.conf import settings
from django.http import Http404
from django.test import tag
from django.urls import reverse
from error_report.models import Error
@@ -10,12 +11,16 @@ from InvenTree.exceptions import log_error
from InvenTree.unit_test import InvenTreeTestCase
# TODO change test to not rely on CUI
@tag('cui')
class MiddlewareTests(InvenTreeTestCase):
"""Test for middleware functions."""
def check_path(self, url, code=200, **kwargs):
"""Helper function to run a request."""
response = self.client.get(url, HTTP_ACCEPT='application/json', **kwargs)
response = self.client.get(
url, headers={'accept': 'application/json'}, **kwargs
)
self.assertEqual(response.status_code, code)
return response

@@ -4,10 +4,11 @@ import os
import re
from pathlib import Path
from django.test import TestCase
from django.test import TestCase, tag
from django.urls import reverse
@tag('cui')
class URLTest(TestCase):
"""Test all files for broken url tags."""

@@ -3,6 +3,7 @@
import os
from django.contrib.auth import get_user_model
from django.test import tag
from django.urls import reverse
from InvenTree.unit_test import InvenTreeTestCase
@@ -35,6 +36,7 @@ class ViewTests(InvenTreeTestCase):
return str(response.content.decode())
@tag('cui')
def test_panels(self):
"""Test that the required 'panels' are present."""
content = self.get_index_page()
@@ -43,6 +45,7 @@ class ViewTests(InvenTreeTestCase):
# TODO: In future, run the javascript and ensure that the panels get created!
@tag('cui')
def test_settings_page(self):
"""Test that the 'settings' page loads correctly."""
# Settings page loads
@@ -101,6 +104,8 @@ class ViewTests(InvenTreeTestCase):
self.assertNotIn(f'select-{panel}', content)
self.assertNotIn(f'panel-{panel}', content)
# TODO: Replace this with a PUI test
@tag('cui')
def test_url_login(self):
"""Test logging in via arguments."""
# Log out

@@ -10,16 +10,18 @@ from unittest import mock
import django.core.exceptions as django_exceptions
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core import mail
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from django.test import TestCase, override_settings, tag
from django.urls import reverse
from django.utils import timezone
import pint.errors
import pytz
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
from sesame.utils import get_user
import InvenTree.conversion
@@ -29,6 +31,7 @@ import InvenTree.helpers_model
import InvenTree.tasks
from common.models import CustomUnit, InvenTreeSetting
from common.settings import currency_codes
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
from InvenTree.sanitizer import sanitize_svg
from InvenTree.unit_test import InvenTreeTestCase
from part.models import Part, PartCategory
@@ -39,6 +42,147 @@ from .tasks import offload_task
from .validators import validate_overage
class HostTest(InvenTreeTestCase):
"""Test for host configuration."""
@override_settings(ALLOWED_HOSTS=['testserver'])
def test_allowed_hosts(self):
"""Test that the ALLOWED_HOSTS functions as expected."""
self.assertIn('testserver', settings.ALLOWED_HOSTS)
response = self.client.get('/api/', headers={'host': 'testserver'})
self.assertEqual(response.status_code, 200)
response = self.client.get('/api/', headers={'host': 'invalidserver'})
self.assertEqual(response.status_code, 400)
@override_settings(ALLOWED_HOSTS=['invalidserver.co.uk'])
def test_allowed_hosts_2(self):
"""Another test for ALLOWED_HOSTS functionality."""
response = self.client.get('/api/', headers={'host': 'invalidserver.co.uk'})
self.assertEqual(response.status_code, 200)
class CorsTest(TestCase):
"""Unit tests for CORS functionality."""
def cors_headers(self):
"""Return a list of CORS headers."""
return [
'access-control-allow-origin',
'access-control-allow-credentials',
'access-control-allow-methods',
'access-control-allow-headers',
]
def preflight(self, url, origin, method='GET'):
"""Make a CORS preflight request to the specified URL."""
headers = {'origin': origin, 'access-control-request-method': method}
return self.client.options(url, headers=headers)
def test_no_origin(self):
"""Test that CORS headers are not included for regular requests.
- We use the /api/ endpoint for this test (it does not require auth)
- By default, in debug mode *all* CORS origins are allowed
"""
# Perform an initial response without the "origin" header
response = self.client.get('/api/')
self.assertEqual(response.status_code, 200)
for header in self.cors_headers():
self.assertNotIn(header, response.headers)
# Now, perform a "preflight" request with the "origin" header
response = self.preflight('/api/', origin='http://random-external-server.com')
self.assertEqual(response.status_code, 200)
for header in self.cors_headers():
self.assertIn(header, response.headers)
self.assertEqual(response.headers['content-length'], '0')
self.assertEqual(
response.headers['access-control-allow-origin'],
'http://random-external-server.com',
)
@override_settings(
CORS_ALLOW_ALL_ORIGINS=False,
CORS_ALLOWED_ORIGINS=['http://my-external-server.com'],
CORS_ALLOWED_ORIGIN_REGEXES=[],
)
def test_auth_view(self):
"""Test that CORS requests work for the /auth/ view.
Here, we are not authorized by default,
but the CORS headers should still be included.
"""
url = '/auth/'
# First, a preflight request with a "valid" origin
response = self.preflight(url, origin='http://my-external-server.com')
self.assertEqual(response.status_code, 200)
for header in self.cors_headers():
self.assertIn(header, response.headers)
# Next, a preflight request with an "invalid" origin
response = self.preflight(url, origin='http://random-external-server.com')
self.assertEqual(response.status_code, 200)
for header in self.cors_headers():
self.assertNotIn(header, response.headers)
# Next, make a GET request (without a token)
response = self.client.get(
url, headers={'origin': 'http://my-external-server.com'}
)
# Unauthorized
self.assertEqual(response.status_code, 401)
self.assertIn('access-control-allow-origin', response.headers)
self.assertNotIn('access-control-allow-methods', response.headers)
@override_settings(
CORS_ALLOW_ALL_ORIGINS=False,
CORS_ALLOWED_ORIGINS=[],
CORS_ALLOWED_ORIGIN_REGEXES=['http://.*myserver.com'],
)
def test_cors_regex(self):
"""Test that CORS regexes work as expected."""
valid_urls = [
'http://www.myserver.com',
'http://test.myserver.com',
'http://myserver.com',
'http://www.myserver.com:8080',
]
invalid_urls = [
'http://myserver.org',
'http://www.other-server.org',
'http://google.com',
'http://myserver.co.uk:8080',
]
for url in valid_urls:
response = self.preflight('/api/', origin=url)
self.assertEqual(response.status_code, 200)
self.assertIn('access-control-allow-origin', response.headers)
for url in invalid_urls:
response = self.preflight('/api/', origin=url)
self.assertEqual(response.status_code, 200)
self.assertNotIn('access-control-allow-origin', response.headers)
class ConversionTest(TestCase):
"""Tests for conversion of physical units."""
@@ -57,6 +201,68 @@ class ConversionTest(TestCase):
q = InvenTree.conversion.convert_physical_value(val, 'm')
self.assertAlmostEqual(q, expected, 3)
def test_engineering_units(self):
"""Test that conversion works with engineering notation."""
# Run some basic checks over the helper function
tests = [
('3', '3'),
('3k3', '3.3k'),
('123R45', '123.45R'),
('10n5F', '10.5nF'),
]
for val, expected in tests:
self.assertEqual(
InvenTree.conversion.from_engineering_notation(val), expected
)
# Now test the conversion function
tests = [('33k3ohm', 33300), ('123kohm45', 123450), ('10n005', 0.000000010005)]
for val, expected in tests:
output = InvenTree.conversion.convert_physical_value(
val, 'ohm', strip_units=True
)
self.assertAlmostEqual(output, expected, 12)
def test_scientific_notation(self):
"""Test that scientific notation is handled correctly."""
tests = [
('3E2', 300),
('-12.3E-3', -0.0123),
('1.23E-3', 0.00123),
('99E9', 99000000000),
]
for val, expected in tests:
output = InvenTree.conversion.convert_physical_value(val, strip_units=True)
self.assertAlmostEqual(output, expected, 6)
def test_temperature_units(self):
"""Test conversion of temperature units.
Ref: https://github.com/inventree/InvenTree/issues/6495
"""
tests = [
('3.3°F', '°C', -15.944),
('273°K', '°F', 31.73),
('900', '°C', 900),
('900°F', 'degF', 900),
('900°K', '°C', 626.85),
('800', 'kelvin', 800),
('-100°C', 'fahrenheit', -148),
('-100 °C', 'Fahrenheit', -148),
('-100 Celsius', 'fahrenheit', -148),
('-123.45 fahrenheit', 'kelvin', 186.7888),
('-99Fahrenheit', 'Celsius', -72.7777),
]
for val, unit, expected in tests:
output = InvenTree.conversion.convert_physical_value(
val, unit, strip_units=True
)
self.assertAlmostEqual(output, expected, 3)
def test_base_units(self):
"""Test conversion to specified base units."""
tests = {
@@ -75,6 +281,24 @@ class ConversionTest(TestCase):
q = InvenTree.conversion.convert_physical_value(val, 'W', strip_units=False)
self.assertAlmostEqual(float(q.magnitude), expected, places=2)
def test_imperial_lengths(self):
"""Test support of imperial length measurements."""
tests = [
('1 inch', 'mm', 25.4),
('1 "', 'mm', 25.4),
('2 "', 'inches', 2),
('3 feet', 'inches', 36),
("3'", 'inches', 36),
("7 '", 'feet', 7),
]
for val, unit, expected in tests:
output = InvenTree.conversion.convert_physical_value(
val, unit, strip_units=True
)
self.assertAlmostEqual(output, expected, 3)
def test_dimensionless_units(self):
"""Tests for 'dimensionless' unit quantities."""
# Test some dimensionless units
@@ -320,35 +544,37 @@ class FormatTest(TestCase):
def test_currency_formatting(self):
"""Test that currency formatting works correctly for multiple currencies."""
test_data = (
(Money(3651.285718, 'USD'), 4, '$3,651.2857'), # noqa: E201,E202
(Money(487587.849178, 'CAD'), 5, 'CA$487,587.84918'), # noqa: E201,E202
(Money(0.348102, 'EUR'), 1, '0.3'), # noqa: E201,E202
(Money(0.916530, 'GBP'), 1, '£0.9'), # noqa: E201,E202
(Money(61.031024, 'JPY'), 3, '¥61.031'), # noqa: E201,E202
(Money(49609.694602, 'JPY'), 1, '¥49,609.7'), # noqa: E201,E202
(Money(155565.264777, 'AUD'), 2, 'A$155,565.26'), # noqa: E201,E202
(Money(0.820437, 'CNY'), 4, 'CN¥0.8204'), # noqa: E201,E202
(Money(7587.849178, 'EUR'), 0, '€7,588'), # noqa: E201,E202
(Money(0.348102, 'GBP'), 3, '£0.348'), # noqa: E201,E202
(Money(0.652923, 'CHF'), 0, 'CHF1'), # noqa: E201,E202
(Money(0.820437, 'CNY'), 1, 'CN¥0.8'), # noqa: E201,E202
(Money(98789.5295680, 'CHF'), 0, 'CHF98,790'), # noqa: E201,E202
(Money(0.585787, 'USD'), 1, '$0.6'), # noqa: E201,E202
(Money(0.690541, 'CAD'), 3, 'CA$0.691'), # noqa: E201,E202
(Money(427.814104, 'AUD'), 5, 'A$427.81410'), # noqa: E201,E202
(Money(3651.285718, 'USD'), 4, True, '$3,651.2857'), # noqa: E201,E202
(Money(487587.849178, 'CAD'), 5, True, 'CA$487,587.84918'), # noqa: E201,E202
(Money(0.348102, 'EUR'), 1, False, '0.3'), # noqa: E201,E202
(Money(0.916530, 'GBP'), 1, True, '£0.9'), # noqa: E201,E202
(Money(61.031024, 'JPY'), 3, False, '61.031'), # noqa: E201,E202
(Money(49609.694602, 'JPY'), 1, True, '¥49,609.7'), # noqa: E201,E202
(Money(155565.264777, 'AUD'), 2, False, '155,565.26'), # noqa: E201,E202
(Money(0.820437, 'CNY'), 4, True, 'CN¥0.8204'), # noqa: E201,E202
(Money(7587.849178, 'EUR'), 0, True, '€7,588'), # noqa: E201,E202
(Money(0.348102, 'GBP'), 3, False, '0.348'), # noqa: E201,E202
(Money(0.652923, 'CHF'), 0, True, 'CHF1'), # noqa: E201,E202
(Money(0.820437, 'CNY'), 1, True, 'CN¥0.8'), # noqa: E201,E202
(Money(98789.5295680, 'CHF'), 0, False, '98,790'), # noqa: E201,E202
(Money(0.585787, 'USD'), 1, True, '$0.6'), # noqa: E201,E202
(Money(0.690541, 'CAD'), 3, True, 'CA$0.691'), # noqa: E201,E202
(Money(427.814104, 'AUD'), 5, True, 'A$427.81410'), # noqa: E201,E202
)
with self.settings(LANGUAGE_CODE='en-us'):
for value, decimal_places, expected_result in test_data:
for value, decimal_places, include_symbol, expected_result in test_data:
result = InvenTree.format.format_money(
value, decimal_places=decimal_places
value, decimal_places=decimal_places, include_symbol=include_symbol
)
assert result == expected_result
self.assertEqual(result, expected_result)
class TestHelpers(TestCase):
"""Tests for InvenTree helper functions."""
@override_settings(SITE_URL=None)
def test_absolute_url(self):
"""Test helper function for generating an absolute URL."""
base = 'https://demo.inventree.org:12345'
@@ -372,7 +598,7 @@ class TestHelpers(TestCase):
for url, expected in tests.items():
# Test with supplied base URL
self.assertEqual(
InvenTree.helpers_model.construct_absolute_url(url, site_url=base),
InvenTree.helpers_model.construct_absolute_url(url, base_url=base),
expected,
)
@@ -508,6 +734,61 @@ class TestHelpers(TestCase):
self.assertNotIn(PartCategory, models)
self.assertNotIn(InvenTreeSetting, models)
def test_test_key(self):
"""Test for the generateTestKey function."""
tests = {
' Hello World ': 'helloworld',
' MY NEW TEST KEY ': 'mynewtestkey',
' 1234 5678': '_12345678',
' 100 percenT': '_100percent',
' MY_NEW_TEST': 'my_new_test',
' 100_new_tests': '_100_new_tests',
}
for name, key in tests.items():
self.assertEqual(helpers.generateTestKey(name), key)
class TestTimeFormat(TestCase):
"""Unit test for time formatting functionality."""
@override_settings(TIME_ZONE='UTC')
def test_tz_utc(self):
"""Check UTC timezone."""
self.assertEqual(InvenTree.helpers.server_timezone(), 'UTC')
@override_settings(TIME_ZONE='Europe/London')
def test_tz_london(self):
"""Check London timezone."""
self.assertEqual(InvenTree.helpers.server_timezone(), 'Europe/London')
@override_settings(TIME_ZONE='Australia/Sydney')
def test_to_local_time(self):
"""Test that the local time conversion works as expected."""
source_time = timezone.datetime(
year=2000,
month=1,
day=1,
hour=0,
minute=0,
second=0,
tzinfo=pytz.timezone('Europe/London'),
)
tests = [
('UTC', '2000-01-01 00:01:00+00:00'),
('Europe/London', '2000-01-01 00:00:00-00:01'),
('America/New_York', '1999-12-31 19:01:00-05:00'),
# All following tests should result in the same value
('Australia/Sydney', '2000-01-01 11:01:00+11:00'),
(None, '2000-01-01 11:01:00+11:00'),
('', '2000-01-01 11:01:00+11:00'),
]
for tz, expected in tests:
local_time = InvenTree.helpers.to_local_time(source_time, tz)
self.assertEqual(str(local_time), expected)
class TestQuoteWrap(TestCase):
"""Tests for string wrapping."""
@@ -816,6 +1097,7 @@ class TestVersionNumber(TestCase):
hash = str(
subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8'
).strip()
self.assertEqual(hash, version.inventreeCommitHash())
d = (
@@ -990,9 +1272,12 @@ class TestSettings(InvenTreeTestCase):
)
# with env set
with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}):
with self.in_env_context({
'INVENTREE_CONFIG_FILE': '_testfolder/my_special_conf.yaml'
}):
self.assertIn(
'inventree/my_special_conf.yaml', str(config.get_config_file()).lower()
'inventree/_testfolder/my_special_conf.yaml',
str(config.get_config_file()).lower(),
)
def test_helpers_plugin_file(self):
@@ -1006,8 +1291,12 @@ class TestSettings(InvenTreeTestCase):
)
# with env set
with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}):
self.assertIn('my_special_plugins.txt', str(config.get_plugin_file()))
with self.in_env_context({
'INVENTREE_PLUGIN_FILE': '_testfolder/my_special_plugins.txt'
}):
self.assertIn(
'_testfolder/my_special_plugins.txt', str(config.get_plugin_file())
)
def test_helpers_setting(self):
"""Test get_setting."""
@@ -1049,10 +1338,17 @@ class TestInstanceName(InvenTreeTestCase):
self.assertEqual(version.inventreeInstanceTitle(), 'Testing title')
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
# The site should also be changed
site_obj = Site.objects.all().order_by('id').first()
self.assertEqual(site_obj.name, 'Testing title')
@override_settings(SITE_URL=None)
def test_instance_url(self):
"""Test instance url settings."""
# Set up required setting
@@ -1060,9 +1356,18 @@ class TestInstanceName(InvenTreeTestCase):
'INVENTREE_BASE_URL', 'http://127.1.2.3', self.user
)
# No further tests if multi-site support is not enabled
if not settings.SITE_MULTI:
return
# The site should also be changed
site_obj = Site.objects.all().order_by('id').first()
self.assertEqual(site_obj.domain, 'http://127.1.2.3')
try:
from django.contrib.sites.models import Site
site_obj = Site.objects.all().order_by('id').first()
self.assertEqual(site_obj.domain, 'http://127.1.2.3')
except Exception:
pass
class TestOffloadTask(InvenTreeTestCase):
@@ -1234,7 +1539,7 @@ class MagicLoginTest(InvenTreeTestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {'status': 'ok'})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, '[example.com] Log in to the app')
self.assertEqual(mail.outbox[0].subject, '[InvenTree] Log in to the app')
# Check that the token is in the email
self.assertTrue('http://testserver/api/email/login/' in mail.outbox[0].body)
@@ -1247,9 +1552,155 @@ class MagicLoginTest(InvenTreeTestCase):
# Check that the login works
resp = self.client.get(reverse('sesame-login') + '?sesame=' + token)
self.assertEqual(resp.status_code, 302)
self.assertEqual(resp.url, '/index/')
# Note: 2023-08-08 - This test has been changed because "platform UI" is not generally available yet
# TODO: In the future, the URL comparison will need to be reverted
# self.assertEqual(resp.url, f'/{settings.FRONTEND_URL_BASE}/logged-in/')
self.assertEqual(resp.url, '/api/auth/login-redirect/')
# And we should be logged in again
self.assertEqual(resp.wsgi_request.user, self.user)
# TODO - refactor to not use CUI
@tag('cui')
class MaintenanceModeTest(InvenTreeTestCase):
"""Unit tests for maintenance mode."""
def test_basic(self):
"""Test basic maintenance mode operation."""
for value in [False, True, False]:
set_maintenance_mode(value)
self.assertEqual(get_maintenance_mode(), value)
# API request is blocked in maintenance mode
set_maintenance_mode(True)
response = self.client.get('/api/')
self.assertEqual(response.status_code, 503)
set_maintenance_mode(False)
response = self.client.get('/api/')
self.assertEqual(response.status_code, 200)
def test_timestamp(self):
"""Test that the timestamp value is interpreted correctly."""
KEY = '_MAINTENANCE_MODE'
# Deleting the setting means maintenance mode is off
InvenTreeSetting.objects.filter(key=KEY).delete()
self.assertFalse(get_maintenance_mode())
def set_timestamp(value):
InvenTreeSetting.set_setting(KEY, value, None)
# Test blank value
set_timestamp('')
self.assertFalse(get_maintenance_mode())
# Test timestamp in the past
ts = datetime.now() - timedelta(minutes=10)
set_timestamp(ts.isoformat())
self.assertFalse(get_maintenance_mode())
# Test timestamp in the future
ts = datetime.now() + timedelta(minutes=10)
set_timestamp(ts.isoformat())
self.assertTrue(get_maintenance_mode())
# Set to false, check for empty string
set_maintenance_mode(False)
self.assertFalse(get_maintenance_mode())
self.assertEqual(InvenTreeSetting.get_setting(KEY, None), '')
class ClassValidationMixinTest(TestCase):
"""Tests for the ClassValidationMixin class."""
class BaseTestClass(ClassValidationMixin):
"""A valid class that inherits from ClassValidationMixin."""
NAME: str
def test(self):
"""Test function."""
pass
def test1(self):
"""Test function."""
pass
def test2(self):
"""Test function."""
pass
required_attributes = ['NAME']
required_overrides = [test, [test1, test2]]
class InvalidClass:
"""An invalid class that does not inherit from ClassValidationMixin."""
pass
def test_valid_class(self):
"""Test that a valid class passes the validation."""
class TestClass(self.BaseTestClass):
"""A valid class that inherits from BaseTestClass."""
NAME = 'Test'
def test(self):
"""Test function."""
pass
def test2(self):
"""Test function."""
pass
TestClass.validate()
def test_invalid_class(self):
"""Test that an invalid class fails the validation."""
class TestClass1(self.BaseTestClass):
"""A bad class that inherits from BaseTestClass."""
with self.assertRaisesRegex(
NotImplementedError,
r'\'<.*TestClass1\'>\' did not provide the following attributes: NAME and did not override the required attributes: test, one of test1 or test2',
):
TestClass1.validate()
class TestClass2(self.BaseTestClass):
"""A bad class that inherits from BaseTestClass."""
NAME = 'Test'
def test2(self):
"""Test function."""
pass
with self.assertRaisesRegex(
NotImplementedError,
r'\'<.*TestClass2\'>\' did not override the required attributes: test',
):
TestClass2.validate()
class ClassProviderMixinTest(TestCase):
"""Tests for the ClassProviderMixin class."""
class TestClass(ClassProviderMixin):
"""This class is a dummy class to test the ClassProviderMixin."""
pass
def test_get_provider_file(self):
"""Test the get_provider_file function."""
self.assertEqual(self.TestClass.get_provider_file(), __file__)
def test_provider_plugin(self):
"""Test the provider_plugin function."""
self.assertEqual(self.TestClass.get_provider_plugin(), None)
def test_get_is_builtin(self):
"""Test the get_is_builtin function."""
self.assertTrue(self.TestClass.get_is_builtin())

@@ -19,6 +19,7 @@ from opentelemetry.sdk.metrics.export import (
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
import InvenTree.ready
from InvenTree.version import inventreeVersion
# Logger configuration
@@ -42,6 +43,9 @@ def setup_tracing(
resources_input: The resources to send with the traces.
console: Whether to output the traces to the console.
"""
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
return
if resources_input is None:
resources_input = {}
if auth is None:

@@ -22,6 +22,7 @@ import build.api
import common.api
import company.api
import label.api
import machine.api
import order.api
import part.api
import plugin.api
@@ -35,6 +36,7 @@ from order.urls import order_urls
from part.urls import part_urls
from plugin.urls import get_plugin_urls
from stock.urls import stock_urls
from web.urls import api_urls as web_api_urls
from web.urls import urlpatterns as platform_urls
from .api import APISearchView, InfoView, NotFoundView, VersionTextView, VersionView
@@ -82,8 +84,10 @@ apipatterns = [
path('order/', include(order.api.order_api_urls)),
path('label/', include(label.api.label_api_urls)),
path('report/', include(report.api.report_api_urls)),
path('machine/', include(machine.api.machine_api_urls)),
path('user/', include(users.api.user_urls)),
path('admin/', include(common.api.admin_api_urls)),
path('web/', include(web_api_urls)),
# Plugin endpoints
path('', include(plugin.api.plugin_api_urls)),
# Common endpoints endpoint
@@ -148,6 +152,12 @@ apipatterns = [
SocialAccountDisconnectView.as_view(),
name='social_account_disconnect',
),
path('logout/', users.api.Logout.as_view(), name='api-logout'),
path(
'login-redirect/',
users.api.LoginRedirect.as_view(),
name='api-login-redirect',
),
path('', include('dj_rest_auth.urls')),
]),
),
@@ -352,15 +362,19 @@ translated_javascript_urls = [
]
backendpatterns = [
# "Dynamic" javascript files which are rendered using InvenTree templating.
path('js/dynamic/', include(dynamic_javascript_urls)),
path('js/i18n/', include(translated_javascript_urls)),
path('auth/', include('rest_framework.urls', namespace='rest_framework')),
path('auth/', auth_request),
path('api/', include(apipatterns)),
path('api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
]
if settings.ENABLE_CLASSIC_FRONTEND:
# "Dynamic" javascript files which are rendered using InvenTree templating.
backendpatterns += [
re_path(r'^js/dynamic/', include(dynamic_javascript_urls)),
re_path(r'^js/i18n/', include(translated_javascript_urls)),
]
classic_frontendpatterns = [
# Apps
path('build/', include(build_urls)),
@@ -425,6 +439,15 @@ if settings.ENABLE_CLASSIC_FRONTEND:
frontendpatterns += classic_frontendpatterns
if settings.ENABLE_PLATFORM_FRONTEND:
frontendpatterns += platform_urls
if not settings.ENABLE_CLASSIC_FRONTEND:
# Add a redirect for login views
frontendpatterns += [
path(
'accounts/login/',
RedirectView.as_view(url=settings.FRONTEND_URL_BASE, permanent=False),
name='account_login',
)
]
urlpatterns += frontendpatterns
@@ -450,5 +473,14 @@ urlpatterns.append(
# Send any unknown URLs to the parts page
urlpatterns += [
re_path(r'^.*$', RedirectView.as_view(url='/index/', permanent=False), name='index')
re_path(
r'^.*$',
RedirectView.as_view(
url='/index/'
if settings.ENABLE_CLASSIC_FRONTEND
else settings.FRONTEND_URL_BASE,
permanent=False,
),
name='index',
)
]

@@ -19,7 +19,7 @@ from dulwich.repo import NotGitRepository, Repo
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = '0.14.0 dev'
INVENTREE_SW_VERSION = '0.15.0 dev'
# Discover git
try:

8
InvenTree/_testfolder/.gitignore vendored Normal file

@@ -0,0 +1,8 @@
# Files used for testing
dummy_image.*
_tmp.csv
part_image_123abc.png
label.pdf
label.png
my_special*
_tests*.txt

@@ -51,6 +51,7 @@ class BuildResource(InvenTreeResource):
notes = Field(attribute='notes')
@admin.register(Build)
class BuildAdmin(ImportExportModelAdmin):
"""Class for managing the Build model via the admin interface"""
@@ -83,6 +84,7 @@ class BuildAdmin(ImportExportModelAdmin):
]
@admin.register(BuildItem)
class BuildItemAdmin(admin.ModelAdmin):
"""Class for managing the BuildItem model via the admin interface."""
@@ -98,6 +100,7 @@ class BuildItemAdmin(admin.ModelAdmin):
]
@admin.register(BuildLine)
class BuildLineAdmin(admin.ModelAdmin):
"""Class for managing the BuildLine model via the admin interface"""
@@ -112,8 +115,3 @@ class BuildLineAdmin(admin.ModelAdmin):
'build__reference',
'bom_item__sub_part__name',
]
admin.site.register(Build, BuildAdmin)
admin.site.register(BuildItem, BuildItemAdmin)
admin.site.register(BuildLine, BuildLineAdmin)

@@ -314,11 +314,21 @@ class BuildLineEndpoint:
queryset = BuildLine.objects.all()
serializer_class = build.serializers.BuildLineSerializer
def get_source_build(self) -> Build:
"""Return the source Build object for the BuildLine queryset.
This source build is used to filter the available stock for each BuildLine.
- If this is a "detail" view, use the build associated with the line
- If this is a "list" view, use the build associated with the request
"""
raise NotImplementedError("get_source_build must be implemented in the child class")
def get_queryset(self):
"""Override queryset to select-related and annotate"""
queryset = super().get_queryset()
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset)
source_build = self.get_source_build()
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build)
return queryset
@@ -353,10 +363,26 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
'bom_item__reference',
]
def get_source_build(self) -> Build:
"""Return the target build for the BuildLine queryset."""
try:
build_id = self.request.query_params.get('build', None)
if build_id:
build = Build.objects.get(pk=build_id)
return build
except (Build.DoesNotExist, AttributeError, ValueError):
pass
return None
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a BuildLine object."""
pass
def get_source_build(self) -> Build:
"""Return the target source location for the BuildLine queryset."""
return None
class BuildOrderContextMixin:

@@ -3,12 +3,6 @@
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
from build.models import Build
def update_tree(apps, schema_editor):
# Update the Build MPTT model
Build.objects.rebuild()
class Migration(migrations.Migration):
@@ -49,5 +43,4 @@ class Migration(migrations.Migration):
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop),
]

@@ -57,6 +57,4 @@ class Migration(migrations.Migration):
('build', '0028_builditem_bom_item'),
]
operations = [
migrations.RunPython(assign_bom_items, reverse_code=migrations.RunPython.noop),
]
operations = []

@@ -4,6 +4,7 @@ import decimal
import logging
import os
from datetime import datetime
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
@@ -28,7 +29,6 @@ from build.validators import generate_next_build_reference, validate_build_order
import InvenTree.fields
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.mixins
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
@@ -45,7 +45,7 @@ import users.models
logger = logging.getLogger('inventree')
class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin):
class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, MPTTModel):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes:
@@ -162,7 +162,9 @@ class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBar
def get_absolute_url(self):
"""Return the web URL associated with this BuildOrder"""
return reverse('build-detail', kwargs={'pk': self.id})
if settings.ENABLE_CLASSIC_FRONTEND:
return reverse('build-detail', kwargs={'pk': self.id})
return InvenTree.helpers.pui_url(f'/build/{self.id}')
reference = models.CharField(
unique=True,
@@ -516,9 +518,25 @@ class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBar
return True
@transaction.atomic
def complete_allocations(self, user):
"""Complete all stock allocations for this build order.
- This function is called when a build order is completed
"""
# Remove untracked allocated stock
self.subtract_allocated_stock(user)
# Ensure that there are no longer any BuildItem objects
# which point to this Build Order
self.allocated_stock.delete()
@transaction.atomic
def complete_build(self, user):
"""Mark this build as complete."""
import build.tasks
if self.incomplete_count > 0:
return
@@ -527,12 +545,12 @@ class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBar
self.status = BuildStatus.COMPLETE.value
self.save()
# Remove untracked allocated stock
self.subtract_allocated_stock(user)
# Ensure that there are no longer any BuildItem objects
# which point to this Build Order
self.allocated_stock.delete()
# Offload task to complete build allocations
InvenTree.tasks.offload_task(
build.tasks.complete_build_allocations,
self.pk,
user.pk if user else None
)
# Register an event
trigger_event('build.completed', id=self.pk)
@@ -916,6 +934,11 @@ class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBar
# List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all()
if (common.settings.prevent_build_output_complete_on_incompleted_tests() and output.hasRequiredTests() and not output.passedAllRequiredTests()):
serial = output.serial
raise ValidationError(
_(f"Build output {serial} has not passed all required tests"))
for build_item in allocated_items:
# Complete the allocation of stock for that item
build_item.complete_allocation(user)
@@ -1247,7 +1270,7 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(models.Model):
class BuildLine(InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically.
@@ -1326,7 +1349,7 @@ class BuildLine(models.Model):
return self.allocated_quantity() > self.quantity
class BuildItem(InvenTree.models.MetadataMixin, models.Model):
class BuildItem(InvenTree.models.InvenTreeMetadataModel):
"""A BuildItem links multiple StockItem objects to a Build.
These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed.

@@ -1,5 +1,7 @@
"""JSON serializers for Build API."""
from decimal import Decimal
from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _
@@ -7,22 +9,25 @@ from django.utils.translation import gettext_lazy as _
from django.db import models
from django.db.models import ExpressionWrapper, F, FloatField
from django.db.models import Case, Sum, When, Value
from django.db.models import BooleanField
from django.db.models import BooleanField, Q
from django.db.models.functions import Coalesce
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubquerySum
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializer
import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus
from InvenTree.status_codes import BuildStatusGroups, StockStatus
from stock.models import generate_batch_code, StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer
import common.models
from common.serializers import ProjectCodeSerializer
import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
@@ -519,6 +524,17 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
outputs = data.get('outputs', [])
if common.settings.prevent_build_output_complete_on_incompleted_tests():
errors = []
for output in outputs:
stock_item = output['output']
if stock_item.hasRequiredTests() and not stock_item.passedAllRequiredTests():
serial = stock_item.serial
errors.append(_(f"Build output {serial} has not passed all required tests"))
if errors:
raise ValidationError(errors)
if len(outputs) == 0:
raise ValidationError(_("A list of build outputs must be provided"))
@@ -904,18 +920,24 @@ class BuildAllocationSerializer(serializers.Serializer):
if build_line.bom_item.consumable:
continue
params = {
"build_line": build_line,
"stock_item": stock_item,
"install_into": output,
}
try:
# Create a new BuildItem to allocate stock
build_item, created = BuildItem.objects.get_or_create(
build_line=build_line,
stock_item=stock_item,
install_into=output,
)
if created:
build_item.quantity = quantity
else:
if build_item := BuildItem.objects.filter(**params).first():
# Find an existing BuildItem for this stock item
# If it exists, increase the quantity
build_item.quantity += quantity
build_item.save()
build_item.save()
else:
# Create a new BuildItem to allocate stock
build_item = BuildItem.objects.create(
quantity=quantity,
**params
)
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
@@ -1019,7 +1041,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
"""Determine which extra details fields should be included"""
part_detail = kwargs.pop('part_detail', True)
location_detail = kwargs.pop('location_detail', True)
stock_detail = kwargs.pop('stock_detail', False)
stock_detail = kwargs.pop('stock_detail', True)
build_detail = kwargs.pop('build_detail', False)
super().__init__(*args, **kwargs)
@@ -1055,11 +1077,13 @@ class BuildLineSerializer(InvenTreeModelSerializer):
# Annotated fields
'allocated',
'in_production',
'on_order',
'available_stock',
'available_substitute_stock',
'available_variant_stock',
'total_available_stock',
'external_stock',
]
read_only_fields = [
@@ -1070,26 +1094,54 @@ class BuildLineSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField()
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
# Foreign key fields
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
allocations = BuildItemSerializer(many=True, read_only=True)
# Annotated (calculated) fields
allocated = serializers.FloatField(read_only=True)
on_order = serializers.FloatField(read_only=True)
available_stock = serializers.FloatField(read_only=True)
allocated = serializers.FloatField(
label=_('Allocated Stock'),
read_only=True
)
on_order = serializers.FloatField(
label=_('On Order'),
read_only=True
)
in_production = serializers.FloatField(
label=_('In Production'),
read_only=True
)
available_stock = serializers.FloatField(
label=_('Available Stock'),
read_only=True
)
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
total_available_stock = serializers.FloatField(read_only=True)
external_stock = serializers.FloatField(read_only=True)
@staticmethod
def annotate_queryset(queryset):
def annotate_queryset(queryset, build=None):
"""Add extra annotations to the queryset:
- allocated: Total stock quantity allocated against this build line
- available: Total stock available for allocation against this build line
- on_order: Total stock on order for this build line
- in_production: Total stock currently in production for this build line
Arguments:
queryset: The queryset to annotate
build: The build order to filter against (optional)
Note: If the 'build' is provided, we can use it to filter available stock, depending on the specified location for the build
"""
queryset = queryset.select_related(
'build', 'bom_item',
@@ -1126,6 +1178,23 @@ class BuildLineSerializer(InvenTreeModelSerializer):
ref = 'bom_item__sub_part__'
stock_filter = None
if build is not None and build.take_from is not None:
location = build.take_from
# Filter by locations below the specified location
stock_filter = Q(
location__tree_id=location.tree_id,
location__lft__gte=location.lft,
location__rght__lte=location.rght,
location__level__gte=location.level,
)
# Annotate the "in_production" quantity
queryset = queryset.annotate(
in_production=part.filters.annotate_in_production_quantity(reference=ref)
)
# Annotate the "on_order" quantity
# Difficulty: Medium
queryset = queryset.annotate(
@@ -1133,10 +1202,8 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
# Annotate the "available" quantity
# TODO: In the future, this should be refactored.
# TODO: Note that part.serializers.BomItemSerializer also has a similar annotation
queryset = queryset.alias(
total_stock=part.filters.annotate_total_stock(reference=ref),
total_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref),
)
@@ -1149,11 +1216,21 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
)
external_stock_filter = Q(location__external=True)
if stock_filter:
external_stock_filter &= stock_filter
# Add 'external stock' annotations
queryset = queryset.annotate(
external_stock=part.filters.annotate_total_stock(reference=ref, filter=external_stock_filter)
)
ref = 'bom_item__substitutes__part__'
# Extract similar information for any 'substitute' parts
queryset = queryset.alias(
substitute_stock=part.filters.annotate_total_stock(reference=ref),
substitute_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter),
substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref),
substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref)
)
@@ -1167,7 +1244,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
# Annotate the queryset with 'available variant stock' information
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__')
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__', filter=stock_filter)
queryset = queryset.alias(
variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),

@@ -4,6 +4,7 @@ from datetime import datetime, timedelta
from decimal import Decimal
import logging
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
@@ -24,6 +25,27 @@ import part.models as part_models
logger = logging.getLogger('inventree')
def complete_build_allocations(build_id: int, user_id: int):
"""Complete build allocations for a specified BuildOrder."""
build_order = build.models.Build.objects.filter(pk=build_id).first()
if user_id:
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
logger.warning("Could not complete build allocations for BuildOrder <%s> - User does not exist", build_id)
return
else:
user = None
if not build_order:
logger.warning("Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist", build_id)
return
build_order.complete_allocations(user)
def update_build_order_lines(bom_item_pk: int):
"""Update all BuildOrderLineItem objects which reference a particular BomItem.

@@ -200,6 +200,11 @@
<div id='build-lines-toolbar'>
{% include "filter_list.html" with id='buildlines' %}
</div>
{% if build.take_from %}
<div class='alert alert-block alert-info'>
{% trans "Available stock has been filtered based on specified source location for this build order" %}
</div>
{% endif %}
<table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
</div>
</div>
@@ -373,7 +378,14 @@ onPanelLoad('allocate', function() {
loadBuildLineTable(
"#build-lines-table",
{{ build.pk }},
{}
{
{% if build.take_from %}
location: {{ build.take_from.pk }},
{% endif %}
{% if build.project_code %}
project_code: {{ build.project_code.pk }},
{% endif %}
}
);
});

@@ -822,6 +822,58 @@ class BuildAllocationTest(BuildAPITest):
allocation.refresh_from_db()
self.assertEqual(allocation.quantity, 5000)
def test_fractional_allocation(self):
"""Test allocation of a fractional quantity of stock items.
Ref: https://github.com/inventree/InvenTree/issues/6508
"""
si = StockItem.objects.get(pk=2)
# Find line item
line = self.build.build_lines.all().filter(bom_item__sub_part=si.part).first()
# Test a fractional quantity when the *available* quantity is greater than 1
si.quantity = 100
si.save()
response = self.post(
self.url,
{
"items": [
{
"build_line": line.pk,
"stock_item": si.pk,
"quantity": 0.1616,
}
]
},
expected_code=201
)
# Test a fractional quantity when the *available* quantity is less than 1
si = StockItem.objects.create(
part=si.part,
quantity=0.3159,
tree_id=0,
level=0,
lft=0, rght=0
)
response = self.post(
self.url,
{
"items": [
{
"build_line": line.pk,
"stock_item": si.pk,
"quantity": 0.1616,
}
]
},
expected_code=201,
)
class BuildOverallocationTest(BuildAPITest):
"""Unit tests for over allocation of stock items against a build order.

@@ -1,5 +1,5 @@
"""Unit tests for the 'build' models"""
import uuid
from datetime import datetime, timedelta
from django.test import TestCase
@@ -14,8 +14,8 @@ from InvenTree import status_codes as status
import common.models
import build.tasks
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem
from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
from stock.models import StockItem, StockItemTestResult
from users.models import Owner
import logging
@@ -55,6 +55,76 @@ class BuildTestBase(TestCase):
trackable=True,
)
# create one build with one required test template
cls.tested_part_with_required_test = Part.objects.create(
name="Part having required tests",
description="Why does it matter what my description is?",
assembly=True,
trackable=True,
)
cls.test_template_required = PartTestTemplate.objects.create(
part=cls.tested_part_with_required_test,
test_name="Required test",
description="Required test template description",
required=True,
requires_value=False,
requires_attachment=False
)
ref = generate_next_build_reference()
cls.build_w_tests_trackable = Build.objects.create(
reference=ref,
title="This is a build",
part=cls.tested_part_with_required_test,
quantity=1,
issued_by=get_user_model().objects.get(pk=1),
)
cls.stockitem_with_required_test = StockItem.objects.create(
part=cls.tested_part_with_required_test,
quantity=1,
is_building=True,
serial=uuid.uuid4(),
build=cls.build_w_tests_trackable
)
# now create a part with a non-required test template
cls.tested_part_wo_required_test = Part.objects.create(
name="Part with one non.required test",
description="Why does it matter what my description is?",
assembly=True,
trackable=True,
)
cls.test_template_non_required = PartTestTemplate.objects.create(
part=cls.tested_part_wo_required_test,
test_name="Required test template",
description="Required test template description",
required=False,
requires_value=False,
requires_attachment=False
)
ref = generate_next_build_reference()
cls.build_wo_tests_trackable = Build.objects.create(
reference=ref,
title="This is a build",
part=cls.tested_part_wo_required_test,
quantity=1,
issued_by=get_user_model().objects.get(pk=1),
)
cls.stockitem_wo_required_test = StockItem.objects.create(
part=cls.tested_part_wo_required_test,
quantity=1,
is_building=True,
serial=uuid.uuid4(),
build=cls.build_wo_tests_trackable
)
cls.sub_part_1 = Part.objects.create(
name="Widget A",
description="A widget",
@@ -245,7 +315,7 @@ class BuildTest(BuildTestBase):
def test_init(self):
"""Perform some basic tests before we start the ball rolling"""
self.assertEqual(StockItem.objects.count(), 10)
self.assertEqual(StockItem.objects.count(), 12)
# Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
@@ -558,7 +628,7 @@ class BuildTest(BuildTestBase):
self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 13)
self.assertEqual(StockItem.objects.count(), 15)
# This stock item has been marked as "consumed"
item = StockItem.objects.get(pk=self.stock_1_1.pk)
@@ -573,6 +643,27 @@ class BuildTest(BuildTestBase):
for output in outputs:
self.assertFalse(output.is_building)
def test_complete_with_required_tests(self):
"""Test the prevention completion when a required test is missing feature"""
# with required tests incompleted the save should fail
common.models.InvenTreeSetting.set_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None)
with self.assertRaises(ValidationError):
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
# let's complete the required test and see if it could be saved
StockItemTestResult.objects.create(
stock_item=self.stockitem_with_required_test,
template=self.test_template_required,
result=True
)
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
# let's see if a non required test could be saved
self.build_wo_tests_trackable.complete_build_output(self.stockitem_wo_required_test, None)
def test_overdue_notification(self):
"""Test sending of notifications when a build order is overdue."""
self.build.target_date = datetime.now().date() - timedelta(days=1)

@@ -1,5 +1,7 @@
"""Basic unit tests for the BuildOrder app"""
from django.conf import settings
from django.test import tag
from django.urls import reverse
from datetime import datetime, timedelta
@@ -40,7 +42,8 @@ class BuildTestSimple(InvenTreeTestCase):
def test_url(self):
"""Test URL lookup"""
b1 = Build.objects.get(pk=1)
self.assertEqual(b1.get_absolute_url(), '/build/1/')
if settings.ENABLE_CLASSIC_FRONTEND:
self.assertEqual(b1.get_absolute_url(), '/build/1/')
def test_is_complete(self):
"""Test build completion status"""
@@ -116,11 +119,13 @@ class TestBuildViews(InvenTreeTestCase):
is_building=True,
)
@tag('cui')
def test_build_index(self):
"""Test build index view."""
response = self.client.get(reverse('build-index'))
self.assertEqual(response.status_code, 200)
@tag('cui')
def test_build_detail(self):
"""Test the detail view for a Build object."""
pk = 1

@@ -11,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt
import django_q.models
from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound
@@ -53,7 +54,15 @@ class WebhookView(CsrfExemptMixin, APIView):
permission_classes = []
model_class = common.models.WebhookEndpoint
run_async = False
serializer_class = None
@extend_schema(
responses={
200: OpenApiResponse(
description='Any data can be posted to the endpoint - everything will be passed to the WebhookEndpoint model.'
)
}
)
def post(self, request, endpoint, *args, **kwargs):
"""Process incoming webhook."""
# get webhook definition
@@ -115,6 +124,7 @@ class CurrencyExchangeView(APIView):
"""API endpoint for displaying currency information."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = None
def get(self, request, format=None):
"""Return information on available currency conversions."""
@@ -157,6 +167,7 @@ class CurrencyRefreshView(APIView):
"""
permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser]
serializer_class = None
def post(self, request, *args, **kwargs):
"""Performing a POST request will update currency exchange rates."""
@@ -516,6 +527,7 @@ class BackgroundTaskOverview(APIView):
"""Provides an overview of the background task queue status."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
serializer_class = None
def get(self, request, format=None):
"""Return information about the current status of the background task queue."""
@@ -668,6 +680,13 @@ common_api_urls = [
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
]),
),
path(
'error-report/',
include([
path('<int:pk>/', ErrorMessageDetail.as_view(), name='api-error-detail'),
path('', ErrorMessageList.as_view(), name='api-error-list'),
]),
),
# Project codes
path(
'project-code/',

@@ -13,10 +13,10 @@ import math
import os
import re
import uuid
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from enum import Enum
from secrets import compare_digest
from typing import Any, Callable, Dict, List, Tuple, TypedDict, Union
from typing import Any, Callable, TypedDict, Union
from django.apps import apps
from django.conf import settings
@@ -24,7 +24,6 @@ from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.contrib.sites.models import Site
from django.core.cache import cache
from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
@@ -101,6 +100,10 @@ class BaseURLValidator(URLValidator):
"""Make sure empty values pass."""
value = str(value).strip()
# If a configuration level value has been specified, prevent change
if settings.SITE_URL and value != settings.SITE_URL:
raise ValidationError(_('Site URL is locked by configuration'))
if len(value) == 0:
pass
@@ -108,7 +111,7 @@ class BaseURLValidator(URLValidator):
super().__call__(value)
class ProjectCode(InvenTree.models.MetadataMixin, models.Model):
class ProjectCode(InvenTree.models.InvenTreeMetadataModel):
"""A ProjectCode is a unique identifier for a project."""
@staticmethod
@@ -154,7 +157,7 @@ class SettingsKeyType(TypedDict, total=False):
units: Units of the particular setting (optional)
validator: Validation function/list of functions for the setting (optional, default: None, e.g: bool, int, str, MinValueValidator, ...)
default: Default value or function that returns default value (optional)
choices: (Function that returns) Tuple[str: key, str: display value] (optional)
choices: Function that returns or value of list[tuple[str: key, str: display value]] (optional)
hidden: Hide this setting from settings page (optional)
before_save: Function that gets called after save with *args, **kwargs (optional)
after_save: Function that gets called after save with *args, **kwargs (optional)
@@ -166,9 +169,9 @@ class SettingsKeyType(TypedDict, total=False):
name: str
description: str
units: str
validator: Union[Callable, List[Callable], Tuple[Callable]]
validator: Union[Callable, list[Callable], tuple[Callable]]
default: Union[Callable, Any]
choices: Union[Tuple[str, str], Callable[[], Tuple[str, str]]]
choices: Union[list[tuple[str, str]], Callable[[], list[tuple[str, str]]]]
hidden: bool
before_save: Callable[..., None]
after_save: Callable[..., None]
@@ -185,9 +188,9 @@ class BaseInvenTreeSetting(models.Model):
extra_unique_fields: List of extra fields used to be unique, e.g. for PluginConfig -> plugin
"""
SETTINGS: Dict[str, SettingsKeyType] = {}
SETTINGS: dict[str, SettingsKeyType] = {}
extra_unique_fields: List[str] = []
extra_unique_fields: list[str] = []
class Meta:
"""Meta options for BaseInvenTreeSetting -> abstract stops creation of database entry."""
@@ -223,9 +226,12 @@ class BaseInvenTreeSetting(models.Model):
"""
cache_key = f'BUILD_DEFAULT_VALUES:{str(cls.__name__)}'
if InvenTree.helpers.str2bool(cache.get(cache_key, False)):
# Already built default values
return
try:
if InvenTree.helpers.str2bool(cache.get(cache_key, False)):
# Already built default values
return
except Exception:
pass
try:
existing_keys = cls.objects.filter(**kwargs).values_list('key', flat=True)
@@ -248,7 +254,10 @@ class BaseInvenTreeSetting(models.Model):
)
pass
cache.set(cache_key, True, timeout=3600)
try:
cache.set(cache_key, True, timeout=3600)
except Exception:
pass
def _call_settings_function(self, reference: str, args, kwargs):
"""Call a function associated with a particular setting.
@@ -287,8 +296,7 @@ class BaseInvenTreeSetting(models.Model):
try:
cache.set(ckey, self, timeout=3600)
except TypeError:
# Some characters cause issues with caching; ignore and move on
except Exception:
pass
@classmethod
@@ -329,7 +337,7 @@ class BaseInvenTreeSetting(models.Model):
cls,
*,
exclude_hidden=False,
settings_definition: Union[Dict[str, SettingsKeyType], None] = None,
settings_definition: Union[dict[str, SettingsKeyType], None] = None,
**kwargs,
):
"""Return a list of "all" defined settings.
@@ -349,7 +357,7 @@ class BaseInvenTreeSetting(models.Model):
# Optionally filter by other keys
results = results.filter(**filters)
settings: Dict[str, BaseInvenTreeSetting] = {}
settings: dict[str, BaseInvenTreeSetting] = {}
# Query the database
for setting in results:
@@ -391,7 +399,7 @@ class BaseInvenTreeSetting(models.Model):
cls,
*,
exclude_hidden=False,
settings_definition: Union[Dict[str, SettingsKeyType], None] = None,
settings_definition: Union[dict[str, SettingsKeyType], None] = None,
**kwargs,
):
"""Return a dict of "all" defined global settings.
@@ -406,7 +414,7 @@ class BaseInvenTreeSetting(models.Model):
**kwargs,
)
settings: Dict[str, Any] = {}
settings: dict[str, Any] = {}
for key, setting in all_settings.items():
settings[key] = setting.value
@@ -418,7 +426,7 @@ class BaseInvenTreeSetting(models.Model):
cls,
*,
exclude_hidden=False,
settings_definition: Union[Dict[str, SettingsKeyType], None] = None,
settings_definition: Union[dict[str, SettingsKeyType], None] = None,
**kwargs,
):
"""Check if all required settings are set by definition.
@@ -433,7 +441,7 @@ class BaseInvenTreeSetting(models.Model):
**kwargs,
)
missing_settings: List[str] = []
missing_settings: list[str] = []
for setting in all_settings.values():
if setting.required:
@@ -522,7 +530,11 @@ class BaseInvenTreeSetting(models.Model):
if callable(choices):
# Evaluate the function (we expect it will return a list of tuples...)
return choices()
try:
# Attempt to pass the kwargs to the function, if it doesn't expect them, ignore and call without
return choices(**kwargs)
except TypeError:
return choices()
return choices
@@ -547,16 +559,18 @@ class BaseInvenTreeSetting(models.Model):
# Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True)
# Perform cache lookup by default
do_cache = kwargs.pop('cache', True)
# Prevent saving to the database during data import
if InvenTree.ready.isImportingData():
create = False
do_cache = False
# Prevent saving to the database during migrations
if InvenTree.ready.isRunningMigrations():
create = False
# Perform cache lookup by default
do_cache = kwargs.pop('cache', True)
do_cache = False
ckey = cls.create_cache_key(key, **kwargs)
@@ -568,7 +582,7 @@ class BaseInvenTreeSetting(models.Model):
if cached_setting is not None:
return cached_setting
except AppRegistryNotReady:
except Exception:
# Cache is not ready yet
do_cache = False
@@ -647,7 +661,7 @@ class BaseInvenTreeSetting(models.Model):
return value
@classmethod
def set_setting(cls, key, value, change_user, create=True, **kwargs):
def set_setting(cls, key, value, change_user=None, create=True, **kwargs):
"""Set the value of a particular setting. If it does not exist, option to create it.
Args:
@@ -668,12 +682,24 @@ class BaseInvenTreeSetting(models.Model):
}
try:
setting = cls.objects.get(**filters)
except cls.DoesNotExist:
if create:
setting = cls(key=key, **kwargs)
else:
return
setting = cls.objects.filter(**filters).first()
if not setting:
if create:
setting = cls(key=key, **kwargs)
else:
return
except (OperationalError, ProgrammingError):
if not key.startswith('_'):
logger.warning("Database is locked, cannot set setting '%s'", key)
# Likely the DB is locked - not much we can do here
return
except Exception as exc:
logger.exception(
"Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc))
)
return
# Enforce standard boolean representation
if setting.is_bool():
@@ -700,6 +726,10 @@ class BaseInvenTreeSetting(models.Model):
attempts=attempts - 1,
**kwargs,
)
except (OperationalError, ProgrammingError):
logger.warning("Database is locked, cannot set setting '%s'", key)
# Likely the DB is locked - not much we can do here
pass
except Exception as exc:
# Some other error
logger.exception(
@@ -1065,6 +1095,15 @@ def settings_group_options():
def update_instance_url(setting):
"""Update the first site objects domain to url."""
if not settings.SITE_MULTI:
return
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
site_obj = Site.objects.all().order_by('id').first()
site_obj.domain = setting.value
site_obj.save()
@@ -1072,6 +1111,15 @@ def update_instance_url(setting):
def update_instance_name(setting):
"""Update the first site objects name to instance name."""
if not settings.SITE_MULTI:
return
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
site_obj = Site.objects.all().order_by('id').first()
site_obj.name = setting.value
site_obj.save()
@@ -1132,6 +1180,16 @@ def reload_plugin_registry(setting):
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
class InvenTreeSettingsKeyType(SettingsKeyType):
"""InvenTreeSettingsKeyType has additional properties only global settings support.
Attributes:
requires_restart: If True, a server restart is required after changing the setting
"""
requires_restart: bool
class InvenTreeSetting(BaseInvenTreeSetting):
"""An InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values).
@@ -1139,6 +1197,8 @@ class InvenTreeSetting(BaseInvenTreeSetting):
even if that key does not exist.
"""
SETTINGS: dict[str, InvenTreeSettingsKeyType]
class Meta:
"""Meta options for InvenTreeSetting."""
@@ -1593,6 +1653,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'REPORT_LOG_ERRORS': {
'name': _('Log Report Errors'),
'description': _('Log errors which occur when generating reports'),
'default': False,
'validator': bool,
},
'REPORT_DEFAULT_PAGE_SIZE': {
'name': _('Page Size'),
'description': _('Default page size for PDF reports'),
@@ -1684,6 +1750,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'STOCK_ENFORCE_BOM_INSTALLATION': {
'name': _('Check BOM when installing items'),
'description': _(
'Installed stock items must exist in the BOM for the parent part'
),
'default': True,
'validator': bool,
},
'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Pattern'),
'description': _(
@@ -1843,6 +1917,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
'requires_restart': True,
},
'PLUGIN_UPDATE_CHECK': {
'name': _('Check for plugin updates'),
'description': _('Enable periodic checks for updates to installed plugins'),
'default': True,
'validator': bool,
},
# Settings for plugin mixin features
'ENABLE_PLUGINS_URL': {
'name': _('Enable URL integration'),
@@ -1924,6 +2004,20 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
'name': _('Block Until Tests Pass'),
'description': _(
'Prevent build outputs from being completed until all required tests pass'
),
'default': False,
'validator': bool,
},
'TEST_STATION_DATA': {
'name': _('Enable Test Station Data'),
'description': _('Enable test station data collection for test results'),
'default': False,
'validator': bool,
},
}
typ = 'inventree'
@@ -2318,6 +2412,11 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': True,
'validator': bool,
},
'LAST_USED_PRINTING_MACHINES': {
'name': _('Last used printing machines'),
'description': _('Save the last used printing machines for a user'),
'default': '',
},
}
typ = 'user'
@@ -2827,12 +2926,17 @@ class NotificationMessage(models.Model):
"""Return API endpoint."""
return reverse('api-notifications-list')
def age(self):
def age(self) -> int:
"""Age of the message in seconds."""
delta = now() - self.creation
# Add timezone information if TZ is enabled (in production mode mostly)
delta = now() - (
self.creation.replace(tzinfo=timezone.utc)
if settings.USE_TZ
else self.creation
)
return delta.seconds
def age_human(self):
def age_human(self) -> str:
"""Humanized age."""
return naturaltime(self.creation)

@@ -142,7 +142,7 @@ class NotificationMethod:
return False
# Check if method globally enabled
plg_instance = registry.plugins.get(plg_cls.NAME.lower())
plg_instance = registry.get_plugin(plg_cls.NAME.lower())
if plg_instance and not plg_instance.get_setting(self.GLOBAL_SETTING):
return True
@@ -422,7 +422,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
# Collect possible methods
if delivery_methods is None:
delivery_methods = storage.liste
delivery_methods = storage.liste or []
else:
delivery_methods = delivery_methods - IGNORED_NOTIFICATION_CLS

@@ -59,6 +59,10 @@ class SettingsSerializer(InvenTreeModelSerializer):
units = serializers.CharField(read_only=True)
required = serializers.BooleanField(read_only=True)
typ = serializers.CharField(read_only=True)
def get_choices(self, obj):
"""Returns the choices available for a given item."""
results = []
@@ -148,6 +152,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'model_name',
'api_url',
'typ',
'required',
]
# set Meta class
@@ -195,7 +200,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
user = serializers.PrimaryKeyRelatedField(read_only=True)
read = serializers.BooleanField()
def get_target(self, obj):
def get_target(self, obj) -> dict:
"""Function to resolve generic object reference to target."""
target = get_objectreference(obj, 'target_content_type', 'target_object_id')
@@ -217,7 +222,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
return target
def get_source(self, obj):
def get_source(self, obj) -> dict:
"""Function to resolve generic object reference to source."""
return get_objectreference(obj, 'source_content_type', 'source_object_id')

@@ -14,7 +14,10 @@ def currency_code_default():
"""Returns the default currency code (or USD if not specified)."""
from common.models import InvenTreeSetting
cached_value = cache.get('currency_code_default', '')
try:
cached_value = cache.get('currency_code_default', '')
except Exception:
cached_value = None
if cached_value:
return cached_value
@@ -31,7 +34,10 @@ def currency_code_default():
code = 'USD' # pragma: no cover
# Cache the value for a short amount of time
cache.set('currency_code_default', code, 30)
try:
cache.set('currency_code_default', code, 30)
except Exception:
pass
return code
@@ -56,3 +62,12 @@ def stock_expiry_enabled():
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY', False, create=False)
def prevent_build_output_complete_on_incompleted_tests():
"""Returns True if the completion of the build outputs is disabled until the required tests are passed."""
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting(
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', False, create=False
)

@@ -12,6 +12,7 @@ from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.test.utils import override_settings
from django.urls import reverse
import PIL
@@ -271,6 +272,7 @@ class SettingsTest(InvenTreeTestCase):
print(f"run_settings_check failed for user setting '{key}'")
raise exc
@override_settings(SITE_URL=None)
def test_defaults(self):
"""Populate the settings with default values."""
for key in InvenTreeSetting.SETTINGS.keys():
@@ -658,6 +660,30 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
...
class ErrorReportTest(InvenTreeAPITestCase):
"""Unit tests for the error report API."""
def test_error_list(self):
"""Test error list."""
from InvenTree.exceptions import log_error
url = reverse('api-error-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 0)
# Throw an error!
log_error(
'test error', error_name='My custom error', error_info={'test': 'data'}
)
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 1)
err = response.data[0]
for k in ['when', 'info', 'data', 'path']:
self.assertIn(k, err)
class TaskListApiTests(InvenTreeAPITestCase):
"""Unit tests for the background task API endpoints."""

@@ -33,6 +33,7 @@ class CompanyResource(InvenTreeResource):
clean_model_instances = True
@admin.register(Company)
class CompanyAdmin(ImportExportModelAdmin):
"""Admin class for the Company model."""
@@ -69,6 +70,7 @@ class SupplierPriceBreakInline(admin.TabularInline):
model = SupplierPriceBreak
@admin.register(SupplierPart)
class SupplierPartAdmin(ImportExportModelAdmin):
"""Admin class for the SupplierPart model."""
@@ -105,6 +107,7 @@ class ManufacturerPartResource(InvenTreeResource):
manufacturer_name = Field(attribute='manufacturer__name', readonly=True)
@admin.register(ManufacturerPart)
class ManufacturerPartAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPart model."""
@@ -117,6 +120,7 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'manufacturer')
@admin.register(ManufacturerPartAttachment)
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPartAttachment model."""
@@ -137,6 +141,7 @@ class ManufacturerPartParameterResource(InvenTreeResource):
clean_model_instance = True
@admin.register(ManufacturerPartParameter)
class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPartParameter model."""
@@ -173,6 +178,7 @@ class SupplierPriceBreakResource(InvenTreeResource):
MPN = Field(attribute='part__MPN', readonly=True)
@admin.register(SupplierPriceBreak)
class SupplierPriceBreakAdmin(ImportExportModelAdmin):
"""Admin class for the SupplierPriceBreak model."""
@@ -197,6 +203,7 @@ class AddressResource(InvenTreeResource):
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
@admin.register(Address)
class AddressAdmin(ImportExportModelAdmin):
"""Admin class for the Address model."""
@@ -221,6 +228,7 @@ class ContactResource(InvenTreeResource):
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
@admin.register(Contact)
class ContactAdmin(ImportExportModelAdmin):
"""Admin class for the Contact model."""
@@ -229,15 +237,3 @@ class ContactAdmin(ImportExportModelAdmin):
list_display = ('company', 'name', 'role', 'email', 'phone')
search_fields = ['company', 'name', 'email']
admin.site.register(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
admin.site.register(Address, AddressAdmin)
admin.site.register(Contact, ContactAdmin)

@@ -5,6 +5,7 @@ from datetime import datetime
from decimal import Decimal
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
@@ -24,17 +25,12 @@ import common.settings
import InvenTree.conversion
import InvenTree.fields
import InvenTree.helpers
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.models import (
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
MetadataMixin,
)
from InvenTree.status_codes import PurchaseOrderStatusGroups
@@ -63,7 +59,9 @@ def rename_company_image(instance, filename):
return os.path.join(base, fn)
class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
class Company(
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel
):
"""A Company object represents an external company.
It may be a supplier or a customer or a manufacturer (or a combination)
@@ -219,7 +217,9 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
def get_absolute_url(self):
"""Get the web URL for the detail view for this Company."""
return reverse('company-detail', kwargs={'pk': self.id})
if settings.ENABLE_CLASSIC_FRONTEND:
return reverse('company-detail', kwargs={'pk': self.id})
return InvenTree.helpers.pui_url(f'/company/{self.id}')
def get_image_url(self):
"""Return the URL of the image for this company."""
@@ -250,7 +250,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
).distinct()
class CompanyAttachment(InvenTreeAttachment):
class CompanyAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file or URL attachments against a Company object."""
@staticmethod
@@ -270,7 +270,7 @@ class CompanyAttachment(InvenTreeAttachment):
)
class Contact(MetadataMixin, models.Model):
class Contact(InvenTree.models.InvenTreeMetadataModel):
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
Attributes:
@@ -299,7 +299,7 @@ class Contact(MetadataMixin, models.Model):
role = models.CharField(max_length=100, blank=True)
class Address(models.Model):
class Address(InvenTree.models.InvenTreeModel):
"""An address represents a physical location where the company is located. It is possible for a company to have multiple locations.
Attributes:
@@ -454,7 +454,9 @@ class Address(models.Model):
)
class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
class ManufacturerPart(
InvenTree.models.InvenTreeBarcodeMixin, 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.
Attributes:
@@ -555,7 +557,7 @@ class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
return s
class ManufacturerPartAttachment(InvenTreeAttachment):
class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a ManufacturerPart object."""
@staticmethod
@@ -575,7 +577,7 @@ class ManufacturerPartAttachment(InvenTreeAttachment):
)
class ManufacturerPartParameter(models.Model):
class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
This is used to represent parameters / properties for a particular manufacturer part.
@@ -640,7 +642,12 @@ class SupplierPartManager(models.Manager):
)
class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin):
class SupplierPart(
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
common.models.MetaMixin,
InvenTree.models.InvenTreeModel,
):
"""Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers.
Attributes:
@@ -679,7 +686,9 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
def get_absolute_url(self):
"""Return the web URL of the detail view for this SupplierPart."""
return reverse('supplier-part-detail', kwargs={'pk': self.id})
if settings.ENABLE_CLASSIC_FRONTEND:
return reverse('supplier-part-detail', kwargs={'pk': self.id})
return InvenTree.helpers.pui_url(f'/purchasing/supplier-part/{self.id}')
def api_instance_filters(self):
"""Return custom API filters for this particular instance."""
@@ -895,6 +904,11 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
self.availability_updated = datetime.now()
self.save()
@property
def name(self):
"""Return string representation of own name."""
return str(self)
@property
def manufacturer_string(self):
"""Format a MPN string for this SupplierPart.

@@ -42,11 +42,13 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
"""Metaclass options."""
model = Company
fields = ['pk', 'url', 'name', 'description', 'image']
fields = ['pk', 'url', 'name', 'description', 'image', 'thumbnail']
url = serializers.CharField(source='get_absolute_url', read_only=True)
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
image = InvenTreeImageSerializerField(read_only=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
class AddressSerializer(InvenTreeModelSerializer):
@@ -309,6 +311,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
'manufacturer_part',
'manufacturer_part_detail',
'MPN',
'name',
'note',
'pk',
'barcode_hash',
@@ -395,6 +398,8 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
source='manufacturer_part', part_detail=False, read_only=True
)
name = serializers.CharField(read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True)
# Date fields

@@ -1,10 +1,12 @@
"""Unit tests for Company views (see views.py)."""
from django.test import tag
from django.urls import reverse
from InvenTree.unit_test import InvenTreeTestCase
@tag('cui')
class CompanyViewTest(InvenTreeTestCase):
"""Tests for various 'Company' views."""

@@ -3,6 +3,7 @@
import os
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.test import TestCase
@@ -59,7 +60,8 @@ class CompanySimpleTest(TestCase):
def test_company_url(self):
"""Test the detail URL for a company."""
c = Company.objects.get(pk=1)
self.assertEqual(c.get_absolute_url(), '/company/1/')
if settings.ENABLE_CLASSIC_FRONTEND:
self.assertEqual(c.get_absolute_url(), '/company/1/')
def test_image_renamer(self):
"""Test the company image upload functionality."""

@@ -66,12 +66,6 @@ debug: True
# Or, use the environment variable INVENTREE_ADMIN_URL
#admin_url: 'admin'
# Set enabled frontends
# Use the environment variable INVENTREE_CLASSIC_FRONTEND
# classic_frontend: True
# Use the environment variable INVENTREE_PLATFORM_FRONTEND
# platform_frontend: True
# Configure the system logging level
# Use environment variable INVENTREE_LOG_LEVEL
# Options: DEBUG / INFO / WARNING / ERROR / CRITICAL
@@ -90,8 +84,8 @@ language: en-us
timezone: UTC
# Base URL for the InvenTree server
# Use the environment variable INVENTREE_BASE_URL
# base_url: 'http://localhost:8000'
# Use the environment variable INVENTREE_SITE_URL
# site_url: 'http://localhost:8000'
# Base currency code (or use env var INVENTREE_BASE_CURRENCY)
base_currency: USD
@@ -158,6 +152,7 @@ sentry_enabled: False
# Set this variable to True to enable InvenTree Plugins
# Alternatively, use the environment variable INVENTREE_PLUGINS_ENABLED
plugins_enabled: False
#plugin_noinstall: True
#plugin_file: '/path/to/plugins.txt'
#plugin_dir: '/path/to/plugins/'
@@ -171,28 +166,40 @@ auto_update: False
allowed_hosts:
- '*'
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/ottoyiu/django-cors-headers)
# Following parameters are
cors:
# CORS_ORIGIN_ALLOW_ALL - If True, the whitelist will not be used and all origins will be accepted.
allow_all: True
# Trusted origins (see CSRF_TRUSTED_ORIGINS in Django settings documentation)
# If you are running behind a proxy, you may need to add the proxy address here
# trusted_origins:
# - 'http://localhost'
# - 'http://*.localhost'
# Proxy forwarding settings
# If InvenTree is running behind a proxy, you may need to configure these settings
# Override with the environment variable INVENTREE_USE_X_FORWARDED_HOST
use_x_forwarded_host: false
# Override with the environment variable INVENTREE_USE_X_FORWARDED_PORT
use_x_forwarded_port: false
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/adamchainz/django-cors-headers)
cors:
allow_all: true
allow_credentials: true
# CORS_ORIGIN_WHITELIST - A list of origins that are authorized to make cross-site HTTP requests. Defaults to []
# whitelist:
# - https://example.com
# - https://sub.example.com
# regex:
# MEDIA_ROOT is the local filesystem location for storing uploaded files
#media_root: '/home/inventree/data/media'
# STATIC_ROOT is the local filesystem location for storing static files
#static_root: '/home/inventree/data/static'
### Backup configuration options ###
# INVENTREE_BACKUP_DIR is the local filesystem location for storing backups
backup_storage: django.core.files.storage.FileSystemStorage
#backup_dir: '/home/inventree/data/backup'
#backup_options:
# Background worker options
background:
@@ -200,14 +207,6 @@ background:
timeout: 90
max_attempts: 5
# Optional URL schemes to allow in URL fields
# By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps']
# Uncomment the lines below to allow extra schemes
#extra_url_schemes:
# - mailto
# - git
# - ssh
# Login configuration
login_confirm_days: 3
login_attempts: 5
@@ -215,7 +214,7 @@ login_default_protocol: http
# Remote / proxy login
# These settings can introduce security problems if configured incorrectly. Please read
# https://docs.djangoproject.com/en/4.0/howto/auth-remote-user/ for more details
# https://docs.djangoproject.com/en/4.2/howto/auth-remote-user/ for more details
# The header name should be prefixed by `HTTP`. Please read the docs for more details
# https://docs.djangoproject.com/en/stable/ref/request-response/#django.http.HttpRequest.META
remote_login_enabled: False
@@ -232,23 +231,6 @@ remote_login_header: HTTP_REMOTE_USER
# https://docs.djangoproject.com/en/stable/ref/settings/#logout-redirect-url
#logout_redirect_url: 'index'
# Permit custom authentication backends
#authentication_backends:
# - 'django.contrib.auth.backends.ModelBackend'
# Custom middleware, sometimes needed alongside an authentication backend change.
#middleware:
# - 'django.middleware.security.SecurityMiddleware'
# - 'django.contrib.sessions.middleware.SessionMiddleware'
# - 'django.middleware.locale.LocaleMiddleware'
# - 'django.middleware.common.CommonMiddleware'
# - 'django.middleware.csrf.CsrfViewMiddleware'
# - 'corsheaders.middleware.CorsMiddleware'
# - 'django.contrib.auth.middleware.AuthenticationMiddleware'
# - 'django.contrib.messages.middleware.MessageMiddleware'
# - 'django.middleware.clickjacking.XFrameOptionsMiddleware'
# - 'InvenTree.middleware.AuthRequiredMiddleware'
# Add SSO login-backends (see examples below)
# social_backends:
# - 'allauth.socialaccount.providers.google'
@@ -319,22 +301,8 @@ remote_login_header: HTTP_REMOTE_USER
# logo: img/custom_logo.png
# splash: img/custom_splash.jpg
# Frontend UI settings
# frontend_settings:
# base_url: 'frontend'
# server_list:
# my_server1:
# host: https://demo.inventree.org/
# name: InvenTree Demo
# default_server: my_server1
# show_server_selector: false
# sentry_dsn: https://84f0c3ea90c64e5092e2bf5dfe325725@o1047628.ingest.sentry.io/4504160008273920
# environment: development
# Custom flags
# InvenTree uses django-flags; read more in their docs at https://cfpb.github.io/django-flags/conditions/
# Use environment variable INVENTREE_FLAGS or the settings below
# flags:
# MY_FLAG:
# - condition: 'parameter'
# value: 'my_flag_param1'
# Set enabled frontends
# Use the environment variable INVENTREE_CLASSIC_FRONTEND
# classic_frontend: True
# Use the environment variable INVENTREE_PLATFORM_FRONTEND
# platform_frontend: True

@@ -2,15 +2,24 @@
import inspect
from rest_framework import permissions
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from InvenTree.serializers import EmptySerializer
from .states import StatusCode
class StatusView(APIView):
class StatusViewSerializer(serializers.Serializer):
"""Serializer for the StatusView responses."""
class_name = serializers.CharField()
values = serializers.DictField()
class StatusView(GenericAPIView):
"""Generic API endpoint for discovering information on 'status codes' for a particular model.
This class should be implemented as a subclass for each type of status.
@@ -28,12 +37,19 @@ class StatusView(APIView):
status_model = self.kwargs.get(self.MODEL_REF, None)
if status_model is None:
raise ValidationError(
raise serializers.ValidationError(
f"StatusView view called without '{self.MODEL_REF}' parameter"
)
return status_model
@extend_schema(
description='Retrieve information about a specific status code',
responses={
200: OpenApiResponse(description='Status code information'),
400: OpenApiResponse(description='Invalid request'),
},
)
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""
status_class = self.get_status_model()
@@ -53,15 +69,22 @@ class AllStatusViews(StatusView):
"""Endpoint for listing all defined status models."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = EmptySerializer
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""
data = {}
for status_class in StatusCode.__subclasses__():
data[status_class.__name__] = {
'class': status_class.__name__,
'values': status_class.dict(),
}
def discover_status_codes(parent_status_class, prefix=None):
"""Recursively discover status classes."""
for status_class in parent_status_class.__subclasses__():
name = '__'.join([*(prefix or []), status_class.__name__])
data[name] = {
'class': status_class.__name__,
'values': status_class.dict(),
}
discover_status_codes(status_class, [name])
discover_status_codes(StatusCode)
return Response(data)

@@ -0,0 +1,140 @@
"""Shared templating code."""
import logging
import os
import warnings
from pathlib import Path
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
from django.core.files.storage import default_storage
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
import InvenTree.helpers
from InvenTree.config import ensure_dir
logger = logging.getLogger('inventree')
MEDIA_STORAGE_DIR = Path(settings.MEDIA_ROOT)
class TemplatingMixin:
"""Mixin that contains shared templating code."""
name: str = ''
db: str = ''
def __init__(self, *args, **kwargs):
"""Ensure that the required properties are set."""
super().__init__(*args, **kwargs)
if self.name == '':
raise NotImplementedError('ref must be set')
if self.db == '':
raise NotImplementedError('db must be set')
def create_defaults(self):
"""Function that creates all default templates for the app."""
raise NotImplementedError('create_defaults must be implemented')
def get_src_dir(self, ref_name):
"""Get the source directory for the default templates."""
raise NotImplementedError('get_src_dir must be implemented')
def get_new_obj_data(self, data, filename):
"""Get the data for a new template db object."""
raise NotImplementedError('get_new_obj_data must be implemented')
# Standardized code
def ready(self):
"""This function is called whenever the app is loaded."""
import InvenTree.ready
# skip loading if plugin registry is not loaded or we run in a background thread
if (
not InvenTree.ready.isPluginRegistryLoaded()
or not InvenTree.ready.isInMainThread()
):
return
if not InvenTree.ready.canAppAccessDatabase(allow_test=False):
return # pragma: no cover
with maintenance_mode_on():
try:
self.create_defaults()
except (
AppRegistryNotReady,
IntegrityError,
OperationalError,
ProgrammingError,
):
# Database might not yet be ready
warnings.warn(
f'Database was not ready for creating {self.name}s', stacklevel=2
)
set_maintenance_mode(False)
def create_template_dir(self, model, data):
"""Create folder and database entries for the default templates, if they do not already exist."""
ref_name = model.getSubdir()
# Create root dir for templates
src_dir = self.get_src_dir(ref_name)
dst_dir = MEDIA_STORAGE_DIR.joinpath(self.name, 'inventree', ref_name)
ensure_dir(dst_dir, default_storage)
# Copy each template across (if required)
for entry in data:
self.create_template_file(model, src_dir, entry, ref_name)
def create_template_file(self, model, src_dir, data, ref_name):
"""Ensure a label template is in place."""
# Destination filename
filename = os.path.join(self.name, 'inventree', ref_name, data['file'])
src_file = src_dir.joinpath(data['file'])
dst_file = MEDIA_STORAGE_DIR.joinpath(filename)
do_copy = False
if not dst_file.exists():
logger.info("%s template '%s' is not present", self.name, filename)
do_copy = True
else:
# Check if the file contents are different
src_hash = InvenTree.helpers.hash_file(src_file)
dst_hash = InvenTree.helpers.hash_file(dst_file)
if src_hash != dst_hash:
logger.info("Hash differs for '%s'", filename)
do_copy = True
if do_copy:
logger.info("Copying %s template '%s'", self.name, dst_file)
# Ensure destination dir exists
dst_file.parent.mkdir(parents=True, exist_ok=True)
# Copy file
default_storage.save(filename, src_file.open('rb'))
# Check if a file matching the template already exists
try:
if model.objects.filter(**{self.db: filename}).exists():
return # pragma: no cover
except Exception:
logger.exception(
"Failed to query %s for '%s' - you should run 'invoke update' first!",
self.name,
filename,
)
logger.info("Creating entry for %s '%s'", model, data.get('name'))
try:
model.objects.create(**self.get_new_obj_data(data, filename))
except Exception:
logger.warning("Failed to create %s '%s'", self.name, data['name'])

@@ -4,6 +4,7 @@ from django.core.exceptions import FieldError, ValidationError
from django.http import JsonResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_page, never_cache
from django_filters.rest_framework import DjangoFilterBackend
@@ -13,6 +14,7 @@ from rest_framework.request import clone_request
import build.models
import common.models
import InvenTree.exceptions
import InvenTree.helpers
import label.models
import label.serializers
@@ -232,9 +234,17 @@ class LabelPrintMixin(LabelFilterMixin):
# At this point, we offload the label(s) to the selected plugin.
# The plugin is responsible for handling the request and returning a response.
result = plugin.print_labels(
label, items_to_print, request, printing_options=request.data
)
try:
result = plugin.print_labels(
label,
items_to_print,
request,
printing_options=(serializer.data if serializer else {}),
)
except ValidationError as e:
raise (e)
except Exception as e:
raise ValidationError([_('Error printing label'), str(e)])
if isinstance(result, JsonResponse):
result['plugin'] = plugin.plugin_slug()

@@ -1,68 +1,31 @@
"""label app specification."""
"""Config options for the label app."""
import hashlib
import logging
import os
import shutil
import warnings
from pathlib import Path
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
import InvenTree.helpers
import InvenTree.ready
logger = logging.getLogger('inventree')
from generic.templating.apps import TemplatingMixin
class LabelConfig(AppConfig):
"""App configuration class for the 'label' app."""
class LabelConfig(TemplatingMixin, AppConfig):
"""Configuration class for the "label" app."""
name = 'label'
db = 'label'
def ready(self):
"""This function is called whenever the label app is loaded."""
# skip loading if plugin registry is not loaded or we run in a background thread
if (
not InvenTree.ready.isPluginRegistryLoaded()
or not InvenTree.ready.isInMainThread()
):
return
if InvenTree.ready.isRunningMigrations():
return
if (
InvenTree.ready.canAppAccessDatabase(allow_test=False)
and not InvenTree.ready.isImportingData()
):
try:
self.create_labels() # pragma: no cover
except (
AppRegistryNotReady,
IntegrityError,
OperationalError,
ProgrammingError,
):
# Database might not yet be ready
warnings.warn(
'Database was not ready for creating labels', stacklevel=2
)
def create_labels(self):
def create_defaults(self):
"""Create all default templates."""
# Test if models are ready
import label.models
try:
import label.models
except Exception: # pragma: no cover
# Database is not ready yet
return
assert bool(label.models.StockLocationLabel is not None)
# Create the categories
self.create_labels_category(
self.create_template_dir(
label.models.StockItemLabel,
'stockitem',
[
{
'file': 'qr.html',
@@ -74,9 +37,8 @@ class LabelConfig(AppConfig):
],
)
self.create_labels_category(
self.create_template_dir(
label.models.StockLocationLabel,
'stocklocation',
[
{
'file': 'qr.html',
@@ -95,9 +57,8 @@ class LabelConfig(AppConfig):
],
)
self.create_labels_category(
self.create_template_dir(
label.models.PartLabel,
'part',
[
{
'file': 'part_label.html',
@@ -116,9 +77,8 @@ class LabelConfig(AppConfig):
],
)
self.create_labels_category(
self.create_template_dir(
label.models.BuildLineLabel,
'buildline',
[
{
'file': 'buildline_label.html',
@@ -130,72 +90,18 @@ class LabelConfig(AppConfig):
],
)
def create_labels_category(self, model, ref_name, labels):
"""Create folder and database entries for the default templates, if they do not already exist."""
# Create root dir for templates
src_dir = Path(__file__).parent.joinpath('templates', 'label', ref_name)
def get_src_dir(self, ref_name):
"""Get the source directory."""
return Path(__file__).parent.joinpath('templates', self.name, ref_name)
dst_dir = settings.MEDIA_ROOT.joinpath('label', 'inventree', ref_name)
if not dst_dir.exists():
logger.info("Creating required directory: '%s'", dst_dir)
dst_dir.mkdir(parents=True, exist_ok=True)
# Create labels
for label in labels:
self.create_template_label(model, src_dir, ref_name, label)
def create_template_label(self, model, src_dir, ref_name, label):
"""Ensure a label template is in place."""
filename = os.path.join('label', 'inventree', ref_name, label['file'])
src_file = src_dir.joinpath(label['file'])
dst_file = settings.MEDIA_ROOT.joinpath(filename)
to_copy = False
if dst_file.exists():
# File already exists - let's see if it is the "same"
if InvenTree.helpers.hash_file(dst_file) != InvenTree.helpers.hash_file(
src_file
): # pragma: no cover
logger.info("Hash differs for '%s'", filename)
to_copy = True
else:
logger.info("Label template '%s' is not present", filename)
to_copy = True
if to_copy:
logger.info("Copying label template '%s'", dst_file)
# Ensure destination dir exists
dst_file.parent.mkdir(parents=True, exist_ok=True)
# Copy file
shutil.copyfile(src_file, dst_file)
# Check if a label matching the template already exists
try:
if model.objects.filter(label=filename).exists():
return # pragma: no cover
except Exception:
logger.exception(
"Failed to query label for '%s' - you should run 'invoke update' first!",
filename,
)
logger.info("Creating entry for %s '%s'", model, label['name'])
try:
model.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True,
width=label['width'],
height=label['height'],
)
except Exception:
logger.warning("Failed to create label '%s'", label['name'])
def get_new_obj_data(self, data, filename):
"""Get the data for a new template db object."""
return {
'name': data['name'],
'description': data['description'],
'label': filename,
'filters': '',
'enabled': True,
'width': data['width'],
'height': data['height'],
}

@@ -15,11 +15,11 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import build.models
import InvenTree.models
import part.models
import stock.models
from InvenTree.helpers import normalize, validateFilterString
from InvenTree.helpers_model import get_base_url
from InvenTree.models import MetadataMixin
from plugin.registry import registry
try:
@@ -88,7 +88,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(MetadataMixin, models.Model):
class LabelTemplate(InvenTree.models.InvenTreeMetadataModel):
"""Base class for generic, filterable labels."""
class Meta:
@@ -96,8 +96,13 @@ class LabelTemplate(MetadataMixin, models.Model):
abstract = True
@classmethod
def getSubdir(cls) -> str:
"""Return the subdirectory for this label."""
return cls.SUBDIR
# Each class of label files will be stored in a separate subdirectory
SUBDIR = 'label'
SUBDIR: str = 'label'
# Object we will be printing against (will be filled out later)
object_to_print = None

@@ -15,7 +15,16 @@ class LabelSerializerBase(InvenTreeModelSerializer):
@staticmethod
def label_fields():
"""Generic serializer fields for a label template."""
return ['pk', 'name', 'description', 'label', 'filters', 'enabled']
return [
'pk',
'name',
'description',
'label',
'filters',
'width',
'height',
'enabled',
]
class StockItemLabelSerializer(LabelSerializerBase):

@@ -30,7 +30,7 @@ class LabelTest(InvenTreeAPITestCase):
def setUpTestData(cls):
"""Ensure that some label instances exist as part of init routine."""
super().setUpTestData()
apps.get_app_config('label').create_labels()
apps.get_app_config('label').create_defaults()
def test_default_labels(self):
"""Test that the default label templates are copied across."""
@@ -142,7 +142,8 @@ class LabelTest(InvenTreeAPITestCase):
# Test that each element has been rendered correctly
self.assertIn(f'part: {part_pk} - {part_name}', content)
self.assertIn(f'data: {{"part": {part_pk}}}', content)
self.assertIn(f'http://testserver/part/{part_pk}/', content)
if settings.ENABLE_CLASSIC_FRONTEND:
self.assertIn(f'http://testserver/part/{part_pk}/', content)
# Check that a encoded image has been generated
self.assertIn('data:image/png;charset=utf-8;base64,', content)

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