Merge branch 'inventree:master' into pr/ChristianSchindler/6305

This commit is contained in:
Matthias Mair 2024-03-27 09:32:23 +01:00 committed by GitHub
commit 028b61f26b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
405 changed files with 168310 additions and 101744 deletions

View File

@ -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,37 +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",
"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",

View File

@ -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:

View File

@ -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
View File

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

View File

@ -40,7 +40,7 @@ runs:
# Python installs
- name: Set up Python ${{ env.python_version }}
if: ${{ inputs.python == 'true' }}
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # pin@v5.0.0
with:
python-version: ${{ env.python_version }}
cache: pip
@ -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
View 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

View File

@ -9,6 +9,9 @@ on:
pull_request_target:
types: [ "labeled", "closed" ]
permissions:
contents: read
jobs:
backport:
name: Backport PR
@ -22,7 +25,7 @@ jobs:
)
steps:
- name: Backport Action
uses: sqren/backport-github-action@f54e19901f2a57f8b82360f2490d47ee82ec82c6 # pin@v9.2.2
uses: sqren/backport-github-action@f7073a2287aefc1fa12685eb25a712ab5620445c # pin@v9.2.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
auto_backport_label_prefix: backport-to-

View File

@ -8,6 +8,12 @@ on:
branches:
- l10
env:
python_version: 3.9
permissions:
contents: read
jobs:
check:
@ -21,22 +27,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

View File

@ -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,13 +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:
@ -60,12 +64,14 @@ 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
- name: Set Up Python ${{ env.python_version }}
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0
with:
python-version: ${{ env.python_version }}
- name: Version Check
@ -75,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
@ -103,25 +116,36 @@ 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
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # pin@v3.0.0
uses: docker/setup-buildx-action@2b51285047da1547ffb1b2203d8be4c0af6b1f20 # pin@v3.2.0
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 # pin@v3.1.2
uses: sigstore/cosign-installer@e1523de7571e31dbe865fd2e80c5c7c23ae71eb4 # pin@v3.4.0
- 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'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # pin@v3.0.0
if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true'
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # pin@v3.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into registry ghcr.io
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # pin@v3.0.0
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # pin@v3.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -130,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
@ -151,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 }}

View File

@ -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,7 +20,10 @@ 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
permissions:
contents: read
jobs:
paths-filter:
name: Filter
@ -34,7 +37,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: |
@ -81,12 +84,12 @@ jobs:
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0
with:
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
@ -102,7 +105,7 @@ jobs:
- 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
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0
with:
python-version: ${{ env.python_version }}
- name: Check Config
@ -111,9 +114,12 @@ jobs:
pip install -r docs/requirements.txt
python docs/ci/check_mkdocs_config.py
- name: Check Links
run: |
pip install linkcheckmd requests
python -m linkcheckmd docs --recurse
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
@ -141,9 +147,9 @@ jobs:
dev-install: true
update: true
- name: Export API Documentation
run: invoke schema --ignore-warnings
run: invoke schema --ignore-warnings --filename InvenTree/schema.yml
- name: Upload schema
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # pin@v4.3.1
with:
name: schema.yml
path: InvenTree/schema.yml
@ -157,7 +163,7 @@ jobs:
echo "URL: $url"
curl -s -o api.yaml $url
echo "Downloaded api.yaml"
- name: Check for differences in schemas
- name: Check for differences in API Schema
if: needs.paths-filter.outputs.api == 'false'
run: |
diff --color -u InvenTree/schema.yml api.yaml
@ -178,17 +184,17 @@ jobs:
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'
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@v4
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
repository: inventree/schema
token: ${{ secrets.SCHEMA_PAT }}
- name: Download schema artifact
uses: actions/download-artifact@v3
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
name: schema.yml
- name: Move schema to correct location
@ -196,7 +202,7 @@ jobs:
echo "Version: $version"
mkdir export/${version}
mv schema.yml export/${version}/api.yaml
- uses: stefanzweifel/git-auto-commit-action@v5
- uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # v5.0.0
with:
commit_message: "Update API schema for ${version}"
@ -213,9 +219,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
@ -309,7 +316,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
@ -391,7 +398,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
@ -481,7 +488,7 @@ jobs:
run: cd src/frontend && npx playwright install --with-deps
- name: Run Playwright tests
run: cd src/frontend && npx playwright test
- uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
- uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # pin@v4.3.1
if: always()
with:
name: playwright-report
@ -502,12 +509,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
zip -r frontend-build.zip web/
- uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
- uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # pin@v4.3.1
with:
name: frontend-build
path: InvenTree/web/static/web

View File

@ -5,6 +5,9 @@ on:
release:
types: [ published ]
permissions:
contents: read
jobs:
stable:
@ -37,12 +40,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
View 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@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
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@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
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@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
with:
sarif_file: results.sarif

View File

@ -5,6 +5,9 @@ on:
schedule:
- cron: '24 11 * * *'
permissions:
contents: read
jobs:
stale:
@ -14,7 +17,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # pin@v8.0.0
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # pin@v9.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'

View File

@ -5,6 +5,13 @@ on:
branches:
- master
env:
python_version: 3.9
node_version: 18
permissions:
contents: write
jobs:
build:
@ -18,24 +25,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

14
.gitignore vendored
View File

@ -39,16 +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
schema.yml
# Local static and media file storage (only when running in development mode)
inventree_media
@ -114,3 +104,7 @@ api.yaml
# web frontend (static files)
InvenTree/web/static
# Generated docs files
docs/docs/api/*.yml
docs/docs/api/schema/*.yml

View File

@ -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

View File

@ -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.2.1
rev: v0.3.4
hooks:
- id: ruff-format
args: [--preview]
@ -25,16 +27,16 @@ repos:
--fix,
--preview
]
- repo: https://github.com/jazzband/pip-tools
rev: 7.3.0
- repo: https://github.com/astral-sh/uv-pre-commit
rev: v0.1.24
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.1
@ -60,7 +62,7 @@ repos:
- "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v9.0.0-alpha.2"
rev: "v9.0.0-rc.0"
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

2
.vscode/launch.json vendored
View File

@ -14,7 +14,7 @@
"justMyCode": true
},
{
"name": "Python: Django - 3rd party",
"name": "InvenTree Server - 3rd party",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/InvenTree/manage.py",

2
.vscode/tasks.json vendored
View File

@ -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": [],
},
{

View File

@ -1,263 +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.
## API versioning
The [API version](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/api_version.py) needs to be bumped every time when the API is changed.
## 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!

View File

@ -9,7 +9,7 @@
# - 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
@ -17,17 +17,18 @@ ARG commit_tag=""
ARG commit_hash=""
ARG commit_date=""
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"
@ -60,7 +61,12 @@ RUN apk add --no-cache \
# Image format support
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 && \
py3-pip py3-pillow py3-cffi py3-brotli pango poppler-utils openldap \
# 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,7 +96,7 @@ 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:
@ -140,7 +146,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
@ -163,10 +169,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}

View File

@ -91,7 +91,7 @@ class VersionView(APIView):
})
class VersionSerializer(serializers.Serializer):
class VersionInformationSerializer(serializers.Serializer):
"""Serializer for a single version."""
version = serializers.CharField()
@ -101,21 +101,21 @@ class VersionSerializer(serializers.Serializer):
latest = serializers.BooleanField()
class Meta:
"""Meta class for VersionSerializer."""
"""Meta class for VersionInformationSerializer."""
fields = ['version', 'date', 'gh', 'text', 'latest']
fields = '__all__'
class VersionApiSerializer(serializers.Serializer):
"""Serializer for the version api endpoint."""
VersionSerializer(many=True)
VersionInformationSerializer(many=True)
class VersionTextView(ListAPI):
"""Simple JSON endpoint for InvenTree version text."""
serializer_class = VersionSerializer
serializer_class = VersionInformationSerializer
permission_classes = [permissions.IsAdminUser]
@ -152,6 +152,7 @@ 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,

View File

@ -1,11 +1,70 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 171
INVENTREE_API_VERSION = 185
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v185 - 2024-03-24 : https://github.com/inventree/InvenTree/pull/6836
- Remove /plugin/activate endpoint
- Update docstrings and typing for various API endpoints (no functional changes)
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

View File

@ -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)

View File

@ -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:

View File

@ -0,0 +1,8 @@
"""Helpers for file handling in InvenTree."""
from pathlib import Path
from django.conf import settings
TEMPLATES_DIR = Path(__file__).parent.parent
MEDIA_STORAGE_DIR = settings.MEDIA_ROOT

View File

@ -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

View File

@ -1,5 +1,6 @@
"""Provides helper functions used throughout the InvenTree project."""
import datetime
import hashlib
import io
import json
@ -8,16 +9,19 @@ import os
import os.path
import re
from decimal import Decimal, InvalidOperation
from typing import TypeVar
from pathlib import Path
from typing import TypeVar, Union
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
from django.core.files.storage import default_storage
from django.core.files.storage import Storage, 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
@ -87,11 +91,24 @@ def generateTestKey(test_name: str) -> str:
key = test_name.strip().lower()
key = key.replace(' ', '')
# Remove any characters that cannot be used to represent a variable
key = re.sub(r'[^a-zA-Z0-9_]', '', key)
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 the key starts with a digit, prefix with an underscore
if key[0].isdigit():
if char.isidentifier():
return True
if char.isalnum():
return True
return False
# Remove any characters that cannot be used to represent a variable
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
@ -845,9 +862,87 @@ def hash_barcode(barcode_data):
return str(hash.hexdigest())
def hash_file(filename: str):
def hash_file(filename: Union[str, Path], storage: Union[Storage, None] = None):
"""Return the MD5 hash of a file."""
return hashlib.md5(open(filename, 'rb').read()).hexdigest()
content = (
open(filename, 'rb').read()
if storage is None
else storage.open(str(filename), 'rb').read()
)
return hashlib.md5(content).hexdigest()
def current_time(local=True):
"""Return the current date and time as a datetime object.
- If timezone support is active, returns a timezone aware time
- If timezone support is not active, returns a timezone naive time
Arguments:
local: Return the time in the local timezone, otherwise UTC (default = True)
"""
if settings.USE_TZ:
now = timezone.now()
now = to_local_time(now, target_tz=server_timezone() if local else 'UTC')
return now
else:
return datetime.datetime.now()
def current_date(local=True):
"""Return the current date."""
return current_time(local=local).date()
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(
@ -913,3 +1008,10 @@ def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
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}'

View File

@ -204,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).
@ -213,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 '-'
@ -258,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:

View File

@ -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,6 +32,7 @@ LOCALES = [
('it', _('Italian')),
('ja', _('Japanese')),
('ko', _('Korean')),
('lv', _('Latvian')),
('nl', _('Dutch')),
('no', _('Norwegian')),
('pl', _('Polish')),

View File

@ -74,6 +74,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)
@ -119,7 +120,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

View File

@ -352,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:

View File

@ -22,9 +22,11 @@ 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.version import checkMinPythonVersion, inventreeApiVersion
@ -80,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
)
@ -126,12 +132,20 @@ 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)
@ -146,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
@ -196,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',
@ -240,6 +252,7 @@ MIDDLEWARE = CONFIG.get(
'django.middleware.locale.LocaleMiddleware',
'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
@ -285,7 +298,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():
@ -355,24 +371,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')
@ -460,21 +468,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': {
'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,
}
WSGI_APPLICATION = 'InvenTree.wsgi.application'
"""
@ -488,7 +481,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 = {}
@ -564,10 +557,15 @@ 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 django.db.backends.postgresql.psycopg_any import IsolationLevel
# Connection timeout
if 'connect_timeout' not in db_options:
# The DB server is in the same data center, it should not take very
@ -631,7 +629,11 @@ if 'postgres' in db_engine: # pragma: no cover
serializable = get_boolean_setting(
'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False
)
db_options['isolation_level'] = 4 if serializable else 2
db_options['isolation_level'] = (
IsolationLevel.SERIALIZABLE
if serializable
else IsolationLevel.READ_COMMITTED
)
# Specific options for MySql / MariaDB backend
elif 'mysql' in db_engine: # pragma: no cover
@ -721,7 +723,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}
@ -733,7 +738,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
@ -819,7 +829,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
@ -929,13 +940,20 @@ 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_I18N = True
# Do not use native timezone support in "test" mode
# It generates a *lot* of cruft in the logs
if not TESTING:
USE_TZ = True # pragma: no cover
else:
USE_TZ = False
DATE_INPUT_FORMATS = ['%Y-%m-%d']
@ -974,10 +992,33 @@ if not SITE_MULTI:
ALLOWED_HOSTS = get_setting(
'INVENTREE_ALLOWED_HOSTS',
config_key='allowed_hosts',
default_value=['*'],
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(
@ -988,9 +1029,23 @@ CSRF_TRUSTED_ORIGINS = get_setting(
)
# If a list of trusted is not specified, but a site URL has been specified, use that
if SITE_URL and len(CSRF_TRUSTED_ORIGINS) == 0:
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',
@ -1018,8 +1073,8 @@ CORS_ALLOW_CREDENTIALS = get_boolean_setting(
default_value=True,
)
# Only allow CORS access to API and media endpoints
CORS_URLS_REGEX = r'^/(api|media|static)/.*$'
# 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',
@ -1029,9 +1084,30 @@ CORS_ALLOWED_ORIGINS = get_setting(
)
# If no CORS origins are specified, but a site URL has been specified, use that
if SITE_URL and len(CORS_ALLOWED_ORIGINS) == 0:
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.'
@ -1056,14 +1132,35 @@ SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX = ''
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting(
'INVENTREE_LOGIN_CONFIRM_DAYS', 'login_confirm_days', 3, typecast=int
)
ACCOUNT_LOGIN_ATTEMPTS_LIMIT = get_setting(
'INVENTREE_LOGIN_ATTEMPTS', 'login_attempts', 5, typecast=int
)
# allauth rate limiting: https://docs.allauth.org/en/latest/account/rate_limits.html
# The default login rate limit is "5/m/user,5/m/ip,5/m/key"
login_attempts = get_setting('INVENTREE_LOGIN_ATTEMPTS', 'login_attempts', 5)
try:
login_attempts = int(login_attempts)
login_attempts = f'{login_attempts}/m/ip,{login_attempts}/m/key'
except ValueError:
pass
ACCOUNT_RATE_LIMITS = {'login_failed': login_attempts}
# Default protocol for login
ACCOUNT_DEFAULT_HTTP_PROTOCOL = get_setting(
'INVENTREE_LOGIN_DEFAULT_HTTP_PROTOCOL', 'login_default_protocol', 'http'
'INVENTREE_LOGIN_DEFAULT_HTTP_PROTOCOL', 'login_default_protocol', None
)
if ACCOUNT_DEFAULT_HTTP_PROTOCOL is None:
if SITE_URL and SITE_URL.startswith('https://'):
# auto-detect HTTPS prtoocol
ACCOUNT_DEFAULT_HTTP_PROTOCOL = 'https'
else:
# default to http
ACCOUNT_DEFAULT_HTTP_PROTOCOL = 'http'
ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
ACCOUNT_PREVENT_ENUMERATION = True
ACCOUNT_EMAIL_SUBJECT_PREFIX = EMAIL_SUBJECT_PREFIX
# 2FA
REMOVE_SUCCESS_URL = 'settings'
@ -1127,6 +1224,9 @@ MAINTENANCE_MODE_STATE_BACKEND = 'InvenTree.backends.InvenTreeMaintenanceModeBac
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()
@ -1143,6 +1243,9 @@ PLUGIN_RETRY = get_setting(
) # How often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# Flag to allow table events during testing
TESTING_TABLE_EVENTS = False
# User interface customization values
CUSTOM_LOGO = get_custom_file(
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
@ -1151,7 +1254,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)
@ -1186,3 +1291,23 @@ if CUSTOM_FLAGS:
# Magic login django-sesame
SESAME_MAX_AGE = 300
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}]

View File

@ -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

View File

@ -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."""
@ -421,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>
@ -506,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."""

View File

@ -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,6 +11,8 @@ 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."""

View File

@ -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."""

View File

@ -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

View File

@ -12,10 +12,12 @@ from django.conf import settings
from django.contrib.auth import get_user_model
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
@ -40,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."""
@ -58,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 = {
@ -76,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
@ -321,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'
@ -524,6 +749,47 @@ class TestHelpers(TestCase):
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."""
@ -831,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 = (
@ -1005,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):
@ -1021,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."""
@ -1074,6 +1348,7 @@ class TestInstanceName(InvenTreeTestCase):
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
@ -1282,6 +1557,8 @@ class MagicLoginTest(InvenTreeTestCase):
self.assertEqual(resp.wsgi_request.user, self.user)
# TODO - refactor to not use CUI
@tag('cui')
class MaintenanceModeTest(InvenTreeTestCase):
"""Unit tests for maintenance mode."""

View File

@ -362,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)),
@ -436,6 +440,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
@ -461,5 +474,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',
)
]

View File

@ -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
View 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

View File

@ -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:

View File

@ -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
@ -73,7 +74,7 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
verbose_name = _("Build Order")
verbose_name_plural = _("Build Orders")
OVERDUE_FILTER = Q(status__in=BuildStatusGroups.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
OVERDUE_FILTER = Q(status__in=BuildStatusGroups.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=InvenTree.helpers.current_date())
# Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
@ -120,6 +121,12 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
super().clean()
if common.models.InvenTreeSetting.get_setting('BUILDORDER_REQUIRE_RESPONSIBLE'):
if not self.responsible:
raise ValidationError({
'responsible': _('Responsible user or group must be specified')
})
# Prevent changing target part after creation
if self.has_field_changed('part'):
raise ValidationError({
@ -161,7 +168,9 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
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,16 +525,11 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
return True
@transaction.atomic
def complete_build(self, user):
"""Mark this build as complete."""
if self.incomplete_count > 0:
return
self.completion_date = datetime.now().date()
self.completed_by = user
self.status = BuildStatus.COMPLETE.value
self.save()
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)
@ -533,6 +537,27 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
# 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
self.completion_date = InvenTree.helpers.current_date()
self.completed_by = user
self.status = BuildStatus.COMPLETE.value
self.save()
# 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)
@ -603,7 +628,7 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
output.delete()
# Date of 'completion' is the date the build was cancelled
self.completion_date = datetime.now().date()
self.completion_date = InvenTree.helpers.current_date()
self.completed_by = user
self.status = BuildStatus.CANCELLED.value

View File

@ -920,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))
@ -1077,6 +1083,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
'available_substitute_stock',
'available_variant_stock',
'total_available_stock',
'external_stock',
]
read_only_fields = [
@ -1118,15 +1125,23 @@ class BuildLineSerializer(InvenTreeModelSerializer):
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',
@ -1163,6 +1178,18 @@ 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)
@ -1175,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),
)
@ -1191,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)
)
@ -1209,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'),

View File

@ -1,9 +1,10 @@
"""Background task definitions for the BuildOrder app"""
from datetime import datetime, timedelta
from datetime import 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
@ -13,6 +14,7 @@ from plugin.events import trigger_event
import common.notifications
import build.models
import InvenTree.email
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.tasks
from InvenTree.status_codes import BuildStatusGroups
@ -24,6 +26,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,7 +223,7 @@ def check_overdue_build_orders():
- Look at the 'target_date' of any outstanding BuildOrder objects
- If the 'target_date' expired *yesterday* then the order is just out of date
"""
yesterday = datetime.now().date() - timedelta(days=1)
yesterday = InvenTree.helpers.current_date() - timedelta(days=1)
overdue_orders = build.models.Build.objects.filter(
target_date=yesterday,

View File

@ -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>
@ -374,6 +379,9 @@ onPanelLoad('allocate', function() {
"#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 %}

View File

@ -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.

View File

@ -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

View File

@ -148,7 +148,7 @@ class CurrencyExchangeView(APIView):
response = {
'base_currency': common.models.InvenTreeSetting.get_setting(
'INVENTREE_DEFAULT_CURRENCY', 'USD'
'INVENTREE_DEFAULT_CURRENCY', backup_value='USD'
),
'exchange_rates': {},
'updated': updated,

View File

@ -13,7 +13,7 @@ import math
import os
import re
import uuid
from datetime import datetime, timedelta, timezone
from datetime import timedelta, timezone
from enum import Enum
from secrets import compare_digest
from typing import Any, Callable, TypedDict, Union
@ -101,7 +101,7 @@ class BaseURLValidator(URLValidator):
value = str(value).strip()
# If a configuration level value has been specified, prevent change
if settings.SITE_URL:
if settings.SITE_URL and value != settings.SITE_URL:
raise ValidationError(_('Site URL is locked by configuration'))
if len(value) == 0:
@ -190,6 +190,8 @@ class BaseInvenTreeSetting(models.Model):
SETTINGS: dict[str, SettingsKeyType] = {}
CHECK_SETTING_KEY = False
extra_unique_fields: list[str] = []
class Meta:
@ -226,9 +228,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)
@ -251,7 +256,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.
@ -280,18 +288,17 @@ class BaseInvenTreeSetting(models.Model):
def save_to_cache(self):
"""Save this setting object to cache."""
ckey = self.cache_key
key = self.cache_key
# skip saving to cache if no pk is set
if self.pk is None:
return
logger.debug("Saving setting '%s' to cache", ckey)
logger.debug("Saving setting '%s' to cache", key)
try:
cache.set(ckey, self, timeout=3600)
except TypeError:
# Some characters cause issues with caching; ignore and move on
cache.set(key, self, timeout=3600)
except Exception:
pass
@classmethod
@ -554,28 +561,30 @@ class BaseInvenTreeSetting(models.Model):
# Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True)
# Specify if cache lookup should be performed
do_cache = kwargs.pop('cache', False)
# 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
do_cache = False
# Perform cache lookup by default
do_cache = kwargs.pop('cache', True)
ckey = cls.create_cache_key(key, **kwargs)
cache_key = cls.create_cache_key(key, **kwargs)
if do_cache:
try:
# First attempt to find the setting object in the cache
cached_setting = cache.get(ckey)
cached_setting = cache.get(cache_key)
if cached_setting is not None:
return cached_setting
except AppRegistryNotReady:
except Exception:
# Cache is not ready yet
do_cache = False
@ -628,6 +637,17 @@ class BaseInvenTreeSetting(models.Model):
If it does not exist, return the backup value (default = None)
"""
if (
cls.CHECK_SETTING_KEY
and key not in cls.SETTINGS
and not key.startswith('_')
):
logger.warning(
"get_setting: Setting key '%s' is not defined for class %s",
key,
str(cls),
)
# If no backup value is specified, attempt to retrieve a "default" value
if backup_value is None:
backup_value = cls.get_setting_default(key, **kwargs)
@ -663,6 +683,17 @@ class BaseInvenTreeSetting(models.Model):
change_user: User object (must be staff member to update a core setting)
create: If True, create a new setting if the specified key does not exist.
"""
if (
cls.CHECK_SETTING_KEY
and key not in cls.SETTINGS
and not key.startswith('_')
):
logger.warning(
"set_setting: Setting key '%s' is not defined for class %s",
key,
str(cls),
)
if change_user is not None and not change_user.is_staff:
return
@ -675,12 +706,14 @@ 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)
@ -1190,6 +1223,8 @@ class InvenTreeSetting(BaseInvenTreeSetting):
SETTINGS: dict[str, InvenTreeSettingsKeyType]
CHECK_SETTING_KEY = True
class Meta:
"""Meta options for InvenTreeSetting."""
@ -1644,6 +1679,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'),
@ -1679,7 +1720,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'STOCK_DELETE_DEPLETED_DEFAULT': {
'name': _('Delete Depleted Stock'),
'description': _(
'Determines default behaviour when a stock item is depleted'
'Determines default behavior when a stock item is depleted'
),
'default': True,
'validator': bool,
@ -1735,6 +1776,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': _(
@ -1743,6 +1792,20 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': 'BO-{ref:04d}',
'validator': build.validators.validate_build_order_reference_pattern,
},
'BUILDORDER_REQUIRE_RESPONSIBLE': {
'name': _('Require Responsible Owner'),
'description': _('A responsible owner must be assigned to each order'),
'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,
},
'RETURNORDER_ENABLED': {
'name': _('Enable Return Orders'),
'description': _('Enable return order functionality in the user interface'),
@ -1757,6 +1820,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': 'RMA-{ref:04d}',
'validator': order.validators.validate_return_order_reference_pattern,
},
'RETURNORDER_REQUIRE_RESPONSIBLE': {
'name': _('Require Responsible Owner'),
'description': _('A responsible owner must be assigned to each order'),
'default': False,
'validator': bool,
},
'RETURNORDER_EDIT_COMPLETED_ORDERS': {
'name': _('Edit Completed Return Orders'),
'description': _(
@ -1773,6 +1842,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': 'SO-{ref:04d}',
'validator': order.validators.validate_sales_order_reference_pattern,
},
'SALESORDER_REQUIRE_RESPONSIBLE': {
'name': _('Require Responsible Owner'),
'description': _('A responsible owner must be assigned to each order'),
'default': False,
'validator': bool,
},
'SALESORDER_DEFAULT_SHIPMENT': {
'name': _('Sales Order Default Shipment'),
'description': _('Enable creation of default shipment with sales orders'),
@ -1795,6 +1870,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': 'PO-{ref:04d}',
'validator': order.validators.validate_purchase_order_reference_pattern,
},
'PURCHASEORDER_REQUIRE_RESPONSIBLE': {
'name': _('Require Responsible Owner'),
'description': _('A responsible owner must be assigned to each order'),
'default': False,
'validator': bool,
},
'PURCHASEORDER_EDIT_COMPLETED_ORDERS': {
'name': _('Edit Completed Purchase Orders'),
'description': _(
@ -1981,11 +2062,9 @@ 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'
),
'TEST_STATION_DATA': {
'name': _('Enable Test Station Data'),
'description': _('Enable test station data collection for test results'),
'default': False,
'validator': bool,
},
@ -2025,7 +2104,9 @@ def label_printer_options():
class InvenTreeUserSetting(BaseInvenTreeSetting):
"""An InvenTreeSetting object with a usercontext."""
"""An InvenTreeSetting object with a user context."""
CHECK_SETTING_KEY = True
class Meta:
"""Meta options for InvenTreeUserSetting."""
@ -2064,7 +2145,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool,
},
'HOMEPAGE_BOM_REQUIRES_VALIDATION': {
'name': _('Show unvalidated BOMs'),
'name': _('Show invalid BOMs'),
'description': _('Show BOMs that await validation on the homepage'),
'default': False,
'validator': bool,
@ -2377,6 +2458,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': [int],
'default': '',
},
'DEFAULT_LINE_LABEL_TEMPLATE': {
'name': _('Default build line label template'),
'description': _(
'The build line label template to be automatically selected'
),
'validator': [int],
'default': '',
},
'NOTIFICATION_ERROR_REPORT': {
'name': _('Receive error reports'),
'description': _('Receive notifications for system errors'),
@ -2587,7 +2676,7 @@ class VerificationMethod(Enum):
class WebhookEndpoint(models.Model):
"""Defines a Webhook entdpoint.
"""Defines a Webhook endpoint.
Attributes:
endpoint_id: Path to the webhook,
@ -2826,7 +2915,7 @@ class NotificationEntry(MetaMixin):
@classmethod
def check_recent(cls, key: str, uid: int, delta: timedelta):
"""Test if a particular notification has been sent in the specified time period."""
since = datetime.now().date() - delta
since = InvenTree.helpers.current_date() - delta
entries = cls.objects.filter(key=key, uid=uid, updated__gte=since)
@ -2922,7 +3011,7 @@ class NewsFeedEntry(models.Model):
- published: Date of publishing of the news item
- author: Author of news item
- summary: Summary of the news items content
- read: Was this iteam already by a superuser?
- read: Was this item already by a superuser?
"""
feed_id = models.CharField(verbose_name=_('Id'), unique=True, max_length=250)

View File

@ -63,7 +63,7 @@ class SettingsSerializer(InvenTreeModelSerializer):
typ = serializers.CharField(read_only=True)
def get_choices(self, obj):
def get_choices(self, obj) -> list:
"""Returns the choices available for a given item."""
results = []

View File

@ -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

View File

@ -2,7 +2,7 @@
import logging
import os
from datetime import datetime, timedelta
from datetime import timedelta
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
@ -12,6 +12,7 @@ from django.utils import timezone
import feedparser
import requests
import InvenTree.helpers
from InvenTree.helpers_model import getModelsWithMixin
from InvenTree.models import InvenTreeNotesMixin
from InvenTree.tasks import ScheduledTask, scheduled_task
@ -107,7 +108,7 @@ def delete_old_notes_images():
note.delete()
note_classes = getModelsWithMixin(InvenTreeNotesMixin)
before = datetime.now() - timedelta(days=90)
before = InvenTree.helpers.current_date() - timedelta(days=90)
for note in NotesImage.objects.filter(date__lte=before):
# Find any images which are no longer referenced by a note

View File

@ -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():

View File

@ -82,7 +82,7 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for the CompanyAttachment model."""
"""API endpoint for listing, creating and bulk deleting a CompanyAttachment."""
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
@ -215,7 +215,7 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload)."""
"""API endpoint for listing, creating and bulk deleting a ManufacturerPartAttachment (file upload)."""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer

View File

@ -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
@ -216,7 +217,9 @@ class Company(
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."""
@ -683,7 +686,9 @@ class SupplierPart(
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."""
@ -896,7 +901,7 @@ class SupplierPart(
def update_available_quantity(self, quantity):
"""Update the available quantity for this SupplierPart."""
self.available = quantity
self.availability_updated = datetime.now()
self.availability_updated = InvenTree.helpers.current_time()
self.save()
@property

View File

@ -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):

View File

@ -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."""

View File

@ -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."""

View File

@ -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
@ -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/'
@ -173,9 +168,9 @@ allowed_hosts:
# 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:8000'
# trusted_origins:
# - 'http://localhost'
# - 'http://*.localhost'
# Proxy forwarding settings
# If InvenTree is running behind a proxy, you may need to configure these settings
@ -188,24 +183,23 @@ 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,
allow_all: true
allow_credentials: true
# 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:
@ -213,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
@ -228,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
@ -245,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'
@ -332,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

View File

View File

@ -0,0 +1,134 @@
"""Shared templating code."""
import logging
import warnings
from pathlib import Path
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')
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)
ensure_dir(Path(self.name, 'inventree', ref_name), 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 = Path(self.name, 'inventree', ref_name, data['file'])
src_file = src_dir.joinpath(data['file'])
do_copy = False
if not default_storage.exists(filename):
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(filename, default_storage)
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, filename)
# Ensure destination dir exists
ensure_dir(filename.parent, default_storage)
# 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, str(filename)))
except Exception as _e:
logger.warning(
"Failed to create %s '%s' with error '%s'", self.name, data['name'], _e
)

View File

@ -1,69 +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
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
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 not InvenTree.ready.canAppAccessDatabase(allow_test=False):
return # pragma: no cover
with maintenance_mode_on():
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
)
set_maintenance_mode(False)
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',
@ -75,9 +37,8 @@ class LabelConfig(AppConfig):
],
)
self.create_labels_category(
self.create_template_dir(
label.models.StockLocationLabel,
'stocklocation',
[
{
'file': 'qr.html',
@ -96,9 +57,8 @@ class LabelConfig(AppConfig):
],
)
self.create_labels_category(
self.create_template_dir(
label.models.PartLabel,
'part',
[
{
'file': 'part_label.html',
@ -117,9 +77,8 @@ class LabelConfig(AppConfig):
],
)
self.create_labels_category(
self.create_template_dir(
label.models.BuildLineLabel,
'buildline',
[
{
'file': 'buildline_label.html',
@ -131,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'],
}

View File

@ -1,6 +1,5 @@
"""Label printing models."""
import datetime
import logging
import os
import sys
@ -15,6 +14,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import build.models
import InvenTree.helpers
import InvenTree.models
import part.models
import stock.models
@ -96,8 +96,13 @@ class LabelTemplate(InvenTree.models.InvenTreeMetadataModel):
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
@ -223,8 +228,8 @@ class LabelTemplate(InvenTree.models.InvenTreeMetadataModel):
# Add "basic" context data which gets passed to every label
context['base_url'] = get_base_url(request=request)
context['date'] = datetime.datetime.now().date()
context['datetime'] = datetime.datetime.now()
context['date'] = InvenTree.helpers.current_date()
context['datetime'] = InvenTree.helpers.current_time()
context['request'] = request
context['user'] = request.user
context['width'] = self.width

View File

@ -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):

View File

@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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