mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into pr/ChristianSchindler/6305
This commit is contained in:
commit
028b61f26b
@ -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",
|
||||
|
42
.devcontainer/docker-compose.yml
Normal file
42
.devcontainer/docker-compose.yml
Normal 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:
|
@ -1,21 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Avoiding Dubious Ownership in Dev Containers for setup commands that use git
|
||||
# Note that the local workspace directory is passed through as the first argument $1
|
||||
git config --global --add safe.directory $1
|
||||
|
||||
# create folders
|
||||
mkdir -p $1/dev/{commandhistory,plugins}
|
||||
cd $1
|
||||
git config --global --add safe.directory /home/inventree
|
||||
|
||||
# create venv
|
||||
python3 -m venv $1/dev/venv
|
||||
. $1/dev/venv/bin/activate
|
||||
python3 -m venv /home/inventree/dev/venv --system-site-packages --upgrade-deps
|
||||
. /home/inventree/dev/venv/bin/activate
|
||||
|
||||
# setup InvenTree server
|
||||
pip install invoke
|
||||
invoke update
|
||||
# Run initial InvenTree server setup
|
||||
invoke update -s
|
||||
|
||||
# Configure dev environment
|
||||
invoke setup-dev
|
||||
|
||||
# Install required frontend packages
|
||||
invoke frontend-install
|
||||
|
||||
# remove existing gitconfig created by "Avoiding Dubious Ownership" step
|
||||
|
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@ -1,4 +1,5 @@
|
||||
github: inventree
|
||||
ko_fi: inventree
|
||||
patreon: inventree
|
||||
polar: inventree
|
||||
custom: [paypal.me/inventree]
|
||||
|
15
.github/actions/setup/action.yaml
vendored
15
.github/actions/setup/action.yaml
vendored
@ -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
36
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /docker
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /docs
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/frontend
|
||||
schedule:
|
||||
interval: daily
|
5
.github/workflows/backport.yml
vendored
5
.github/workflows/backport.yml
vendored
@ -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-
|
||||
|
21
.github/workflows/check_translations.yaml
vendored
21
.github/workflows/check_translations.yaml
vendored
@ -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
|
||||
|
66
.github/workflows/docker.yaml
vendored
66
.github/workflows/docker.yaml
vendored
@ -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 }}
|
||||
|
49
.github/workflows/qc_checks.yaml
vendored
49
.github/workflows/qc_checks.yaml
vendored
@ -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
|
||||
|
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@ -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
72
.github/workflows/scorecard.yml
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '32 0 * * 0'
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
# Uncomment the permissions below if installing in a private repository.
|
||||
# contents: read
|
||||
# actions: read
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@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
|
5
.github/workflows/stale.yml
vendored
5
.github/workflows/stale.yml
vendored
@ -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.'
|
||||
|
26
.github/workflows/translations.yml
vendored
26
.github/workflows/translations.yml
vendored
@ -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
14
.gitignore
vendored
@ -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
|
||||
|
@ -19,9 +19,9 @@ before:
|
||||
- contrib/packager.io/before.sh
|
||||
dependencies:
|
||||
- curl
|
||||
- python3.9
|
||||
- python3.9-venv
|
||||
- python3.9-dev
|
||||
- "python3.9 | python3.10 | python3.11"
|
||||
- "python3.9-venv | python3.10-venv | python3.11-venv"
|
||||
- "python3.9-dev | python3.10-dev | python3.11-dev"
|
||||
- python3-pip
|
||||
- python3-cffi
|
||||
- python3-brotli
|
||||
@ -36,4 +36,3 @@ dependencies:
|
||||
targets:
|
||||
ubuntu-20.04: true
|
||||
debian-11: true
|
||||
debian-12: true
|
||||
|
@ -5,7 +5,9 @@ exclude: |
|
||||
InvenTree/InvenTree/static/.*|
|
||||
InvenTree/locale/.*|
|
||||
src/frontend/src/locales/.*|
|
||||
.*/migrations/.*
|
||||
.*/migrations/.* |
|
||||
src/frontend/yarn.lock |
|
||||
yarn.lock
|
||||
)$
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
@ -16,7 +18,7 @@ repos:
|
||||
- id: check-yaml
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.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
2
.vscode/launch.json
vendored
@ -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
2
.vscode/tasks.json
vendored
@ -45,7 +45,7 @@
|
||||
{
|
||||
"label": "setup-test",
|
||||
"type": "shell",
|
||||
"command": "inv setup-test --path dev/inventree-demo-dataset",
|
||||
"command": "inv setup-test -i --path dev/inventree-demo-dataset",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
|
265
CONTRIBUTING.md
265
CONTRIBUTING.md
@ -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!
|
||||
|
25
Dockerfile
25
Dockerfile
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
8
InvenTree/InvenTree/files.py
Normal file
8
InvenTree/InvenTree/files.py
Normal 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
|
@ -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
|
||||
|
@ -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}'
|
||||
|
@ -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:
|
||||
|
@ -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')),
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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}]
|
||||
|
@ -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
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -4,10 +4,11 @@ import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, tag
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@tag('cui')
|
||||
class URLTest(TestCase):
|
||||
"""Test all files for broken url tags."""
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
import os
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
@ -35,6 +36,7 @@ class ViewTests(InvenTreeTestCase):
|
||||
|
||||
return str(response.content.decode())
|
||||
|
||||
@tag('cui')
|
||||
def test_panels(self):
|
||||
"""Test that the required 'panels' are present."""
|
||||
content = self.get_index_page()
|
||||
@ -43,6 +45,7 @@ class ViewTests(InvenTreeTestCase):
|
||||
|
||||
# TODO: In future, run the javascript and ensure that the panels get created!
|
||||
|
||||
@tag('cui')
|
||||
def test_settings_page(self):
|
||||
"""Test that the 'settings' page loads correctly."""
|
||||
# Settings page loads
|
||||
@ -101,6 +104,8 @@ class ViewTests(InvenTreeTestCase):
|
||||
self.assertNotIn(f'select-{panel}', content)
|
||||
self.assertNotIn(f'panel-{panel}', content)
|
||||
|
||||
# TODO: Replace this with a PUI test
|
||||
@tag('cui')
|
||||
def test_url_login(self):
|
||||
"""Test logging in via arguments."""
|
||||
# Log out
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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',
|
||||
)
|
||||
]
|
||||
|
@ -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
8
InvenTree/_testfolder/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Files used for testing
|
||||
dummy_image.*
|
||||
_tmp.csv
|
||||
part_image_123abc.png
|
||||
label.pdf
|
||||
label.png
|
||||
my_special*
|
||||
_tests*.txt
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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,
|
||||
|
@ -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 %}
|
||||
|
@ -822,6 +822,58 @@ class BuildAllocationTest(BuildAPITest):
|
||||
allocation.refresh_from_db()
|
||||
self.assertEqual(allocation.quantity, 5000)
|
||||
|
||||
def test_fractional_allocation(self):
|
||||
"""Test allocation of a fractional quantity of stock items.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/issues/6508
|
||||
"""
|
||||
|
||||
si = StockItem.objects.get(pk=2)
|
||||
|
||||
# Find line item
|
||||
line = self.build.build_lines.all().filter(bom_item__sub_part=si.part).first()
|
||||
|
||||
# Test a fractional quantity when the *available* quantity is greater than 1
|
||||
si.quantity = 100
|
||||
si.save()
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"build_line": line.pk,
|
||||
"stock_item": si.pk,
|
||||
"quantity": 0.1616,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# Test a fractional quantity when the *available* quantity is less than 1
|
||||
si = StockItem.objects.create(
|
||||
part=si.part,
|
||||
quantity=0.3159,
|
||||
tree_id=0,
|
||||
level=0,
|
||||
lft=0, rght=0
|
||||
)
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"build_line": line.pk,
|
||||
"stock_item": si.pk,
|
||||
"quantity": 0.1616,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
|
||||
class BuildOverallocationTest(BuildAPITest):
|
||||
"""Unit tests for over allocation of stock items against a build order.
|
||||
|
@ -1,5 +1,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
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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 = []
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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():
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -1,10 +1,12 @@
|
||||
"""Unit tests for Company views (see views.py)."""
|
||||
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
|
||||
@tag('cui')
|
||||
class CompanyViewTest(InvenTreeTestCase):
|
||||
"""Tests for various 'Company' views."""
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
@ -59,7 +60,8 @@ class CompanySimpleTest(TestCase):
|
||||
def test_company_url(self):
|
||||
"""Test the detail URL for a company."""
|
||||
c = Company.objects.get(pk=1)
|
||||
self.assertEqual(c.get_absolute_url(), '/company/1/')
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
self.assertEqual(c.get_absolute_url(), '/company/1/')
|
||||
|
||||
def test_image_renamer(self):
|
||||
"""Test the company image upload functionality."""
|
||||
|
@ -66,12 +66,6 @@ debug: True
|
||||
# Or, use the environment variable INVENTREE_ADMIN_URL
|
||||
#admin_url: 'admin'
|
||||
|
||||
# Set enabled frontends
|
||||
# Use the environment variable INVENTREE_CLASSIC_FRONTEND
|
||||
# classic_frontend: True
|
||||
# Use the environment variable INVENTREE_PLATFORM_FRONTEND
|
||||
# platform_frontend: True
|
||||
|
||||
# Configure the system logging level
|
||||
# Use environment variable INVENTREE_LOG_LEVEL
|
||||
# Options: DEBUG / INFO / WARNING / ERROR / CRITICAL
|
||||
@ -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
|
||||
|
0
InvenTree/generic/templating/__init__.py
Normal file
0
InvenTree/generic/templating/__init__.py
Normal file
134
InvenTree/generic/templating/apps.py
Normal file
134
InvenTree/generic/templating/apps.py
Normal 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
|
||||
)
|
@ -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'],
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -15,7 +15,16 @@ class LabelSerializerBase(InvenTreeModelSerializer):
|
||||
@staticmethod
|
||||
def label_fields():
|
||||
"""Generic serializer fields for a label template."""
|
||||
return ['pk', 'name', 'description', 'label', 'filters', 'enabled']
|
||||
return [
|
||||
'pk',
|
||||
'name',
|
||||
'description',
|
||||
'label',
|
||||
'filters',
|
||||
'width',
|
||||
'height',
|
||||
'enabled',
|
||||
]
|
||||
|
||||
|
||||
class StockItemLabelSerializer(LabelSerializerBase):
|
||||
|
@ -30,7 +30,7 @@ class LabelTest(InvenTreeAPITestCase):
|
||||
def setUpTestData(cls):
|
||||
"""Ensure that some label instances exist as part of init routine."""
|
||||
super().setUpTestData()
|
||||
apps.get_app_config('label').create_labels()
|
||||
apps.get_app_config('label').create_defaults()
|
||||
|
||||
def test_default_labels(self):
|
||||
"""Test that the default label templates are copied across."""
|
||||
@ -142,7 +142,8 @@ class LabelTest(InvenTreeAPITestCase):
|
||||
# Test that each element has been rendered correctly
|
||||
self.assertIn(f'part: {part_pk} - {part_name}', content)
|
||||
self.assertIn(f'data: {{"part": {part_pk}}}', content)
|
||||
self.assertIn(f'http://testserver/part/{part_pk}/', content)
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
self.assertIn(f'http://testserver/part/{part_pk}/', content)
|
||||
|
||||
# Check that a encoded image has been generated
|
||||
self.assertIn('data:image/png;charset=utf-8;base64,', content)
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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
14162
InvenTree/locale/lv/LC_MESSAGES/django.po
Normal file
14162
InvenTree/locale/lv/LC_MESSAGES/django.po
Normal file
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
Loading…
Reference in New Issue
Block a user