mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-23 01:25:45 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
@@ -71,6 +71,7 @@
|
|||||||
"INVENTREE_DB_NAME": "/workspaces/InvenTree/dev/database.sqlite3",
|
"INVENTREE_DB_NAME": "/workspaces/InvenTree/dev/database.sqlite3",
|
||||||
"INVENTREE_MEDIA_ROOT": "/workspaces/InvenTree/dev/media",
|
"INVENTREE_MEDIA_ROOT": "/workspaces/InvenTree/dev/media",
|
||||||
"INVENTREE_STATIC_ROOT": "/workspaces/InvenTree/dev/static",
|
"INVENTREE_STATIC_ROOT": "/workspaces/InvenTree/dev/static",
|
||||||
|
"INVENTREE_BACKUP_DIR": "/workspaces/InvenTree/dev/backup",
|
||||||
"INVENTREE_CONFIG_FILE": "/workspaces/InvenTree/dev/config.yaml",
|
"INVENTREE_CONFIG_FILE": "/workspaces/InvenTree/dev/config.yaml",
|
||||||
"INVENTREE_SECRET_KEY_FILE": "/workspaces/InvenTree/dev/secret_key.txt",
|
"INVENTREE_SECRET_KEY_FILE": "/workspaces/InvenTree/dev/secret_key.txt",
|
||||||
"INVENTREE_PLUGIN_DIR": "/workspaces/InvenTree/dev/plugins",
|
"INVENTREE_PLUGIN_DIR": "/workspaces/InvenTree/dev/plugins",
|
||||||
|
|||||||
@@ -4,3 +4,9 @@
|
|||||||
# plugins are co-owned
|
# plugins are co-owned
|
||||||
/InvenTree/plugin/ @SchrodingersGat @matmair
|
/InvenTree/plugin/ @SchrodingersGat @matmair
|
||||||
/InvenTree/plugins/ @SchrodingersGat @matmair
|
/InvenTree/plugins/ @SchrodingersGat @matmair
|
||||||
|
|
||||||
|
# Installer functions
|
||||||
|
.pkgr.yml @matmair
|
||||||
|
Procfile @matmair
|
||||||
|
runtime.txt @matmair
|
||||||
|
/contrib/ @matmair
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ runs:
|
|||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
|
|
||||||
# Python installs
|
# Python installs
|
||||||
- name: Set up Python ${{ env.python_version }}
|
- name: Set up Python ${{ env.python_version }}
|
||||||
if: ${{ inputs.python == 'true' }}
|
if: ${{ inputs.python == 'true' }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.python_version }}
|
python-version: ${{ env.python_version }}
|
||||||
cache: pip
|
cache: pip
|
||||||
@@ -58,7 +58,7 @@ runs:
|
|||||||
# NPM installs
|
# NPM installs
|
||||||
- name: Install node.js ${{ env.node_version }}
|
- name: Install node.js ${{ env.node_version }}
|
||||||
if: ${{ inputs.npm == 'true' }}
|
if: ${{ inputs.npm == 'true' }}
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@969bd2663942d722d85b6a8626225850c2f7be4b # pin to v3.5.0
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.node_version }}
|
node-version: ${{ env.node_version }}
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ jobs:
|
|||||||
INVENTREE_DEBUG: info
|
INVENTREE_DEBUG: info
|
||||||
INVENTREE_MEDIA_ROOT: ./media
|
INVENTREE_MEDIA_ROOT: ./media
|
||||||
INVENTREE_STATIC_ROOT: ./static
|
INVENTREE_STATIC_ROOT: ./static
|
||||||
|
INVENTREE_BACKUP_DIR: ./backup
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Version Check
|
- name: Version Check
|
||||||
run: |
|
run: |
|
||||||
pip install requests
|
pip install requests
|
||||||
@@ -66,30 +66,30 @@ jobs:
|
|||||||
test -f data/secret_key.txt
|
test -f data/secret_key.txt
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480 # pin@v1
|
uses: docker/setup-qemu-action@e81a89b1732b9c48d79cd809d8d81d79c4647a18 # pin@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/setup-buildx-action@f211e3e9ded2d9377c8cadc4489a4e38014bc4c9 # pin@v1
|
uses: docker/setup-buildx-action@95cb08cb2672c73d4ffd2f422e6d11953d2a9c70 # pin@v2.1.0
|
||||||
- name: Set up cosign
|
- name: Set up cosign
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: sigstore/cosign-installer@09a077b27eb1310dcfb21981bee195b30ce09de0 # pin@v2.5.0
|
uses: sigstore/cosign-installer@7cc35d7fdbe70d4278a0c96779081e6fac665f88 # pin@v2.8.0
|
||||||
- name: Login to Dockerhub
|
- name: Login to Dockerhub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7 # pin@v1
|
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a # pin@v2.1.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@69f6fc9d46f2f8bf0d5491e4aabe0bb8c6a4678a # pin@v4.0.1
|
uses: docker/metadata-action@12cce9efe0d49980455aaaca9b071c0befcdd702 # pin@v4.1.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
inventree/inventree
|
inventree/inventree
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a # pin@v2
|
uses: docker/build-push-action@c56af957549030174b10d6867f20e78cfd7debc5 # pin@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ env:
|
|||||||
INVENTREE_DB_NAME: inventree
|
INVENTREE_DB_NAME: inventree
|
||||||
INVENTREE_MEDIA_ROOT: ../test_inventree_media
|
INVENTREE_MEDIA_ROOT: ../test_inventree_media
|
||||||
INVENTREE_STATIC_ROOT: ../test_inventree_static
|
INVENTREE_STATIC_ROOT: ../test_inventree_static
|
||||||
|
INVENTREE_BACKUP_DIR: ../test_inventree_backup
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pep_style:
|
pep_style:
|
||||||
@@ -29,7 +30,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
@@ -44,7 +45,7 @@ jobs:
|
|||||||
needs: pep_style
|
needs: pep_style
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
@@ -66,7 +67,7 @@ jobs:
|
|||||||
needs: pep_style
|
needs: pep_style
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
@@ -82,14 +83,14 @@ jobs:
|
|||||||
needs: pep_style
|
needs: pep_style
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Set up Python ${{ env.python_version }}
|
- name: Set up Python ${{ env.python_version }}
|
||||||
uses: actions/setup-python@7f80679172b057fc5e90d70d197929d454754a5a # pin@v2
|
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.python_version }}
|
python-version: ${{ env.python_version }}
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
- name: Run pre-commit Checks
|
- name: Run pre-commit Checks
|
||||||
uses: pre-commit/action@9b88afc9cd57fd75b655d5c71bd38146d07135fe # pin@v2.0.3
|
uses: pre-commit/action@646c83fcd040023954eafda54b4db0192ce70507 # pin@v3.0.0
|
||||||
- name: Check Version
|
- name: Check Version
|
||||||
run: |
|
run: |
|
||||||
pip install requests
|
pip install requests
|
||||||
@@ -113,7 +114,7 @@ jobs:
|
|||||||
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
|
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
@@ -143,7 +144,7 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
@@ -164,7 +165,7 @@ jobs:
|
|||||||
INVENTREE_PLUGINS_ENABLED: true
|
INVENTREE_PLUGINS_ENABLED: true
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
@@ -199,7 +200,7 @@ jobs:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres
|
image: postgres:14
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: inventree
|
POSTGRES_USER: inventree
|
||||||
POSTGRES_PASSWORD: password
|
POSTGRES_PASSWORD: password
|
||||||
@@ -212,7 +213,7 @@ jobs:
|
|||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
@@ -257,7 +258,7 @@ jobs:
|
|||||||
- 3306:3306
|
- 3306:3306
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Version Check
|
- name: Version Check
|
||||||
run: |
|
run: |
|
||||||
pip install requests
|
pip install requests
|
||||||
python3 ci/version_check.py
|
python3 ci/version_check.py
|
||||||
- name: Push to Stable Branch
|
- name: Push to Stable Branch
|
||||||
uses: ad-m/github-push-action@9a46ba8d86d3171233e861a4351b1278a2805c83 # pin@master
|
uses: ad-m/github-push-action@4dcce6dea3e3c8187237fc86b7dfdc93e5aaae58 # pin@master
|
||||||
if: env.stable_release == 'true'
|
if: env.stable_release == 'true'
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
tweet:
|
tweet:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: Eomm/why-don-t-you-tweet@f61f2a86c30c46528c1398a1abb1f64aa0988f69 # pin@v1
|
- uses: Eomm/why-don-t-you-tweet@5936bb1fd0096b1c2bbbb7518746638261bb4dae # pin@v1.0.1
|
||||||
with:
|
with:
|
||||||
tweet-message: "InvenTree release ${{ github.event.release.tag_name }} is out
|
tweet-message: "InvenTree release ${{ github.event.release.tag_name }} is out
|
||||||
now! Release notes: ${{ github.event.release.html_url }} #opensource
|
now! Release notes: ${{ github.event.release.html_url }} #opensource
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
reddit:
|
reddit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: bluwy/release-for-reddit-action@4d948192aff856da22f19f9806b00b46ca384547 # pin@v1
|
- uses: bluwy/release-for-reddit-action@4b2d034b5c86a24db24363f1064149a8c2db69b4 # pin@v1.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.REDDIT_USERNAME }}
|
username: ${{ secrets.REDDIT_USERNAME }}
|
||||||
password: ${{ secrets.REDDIT_PASSWORD }}
|
password: ${{ secrets.REDDIT_PASSWORD }}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@98ed4cb500039dbcccf4bd9bedada4d0187f2757 # pin@v3
|
- uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # pin@v6.0.1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: 'This issue seems stale. Please react to show this is still
|
stale-issue-message: 'This issue seems stale. Please react to show this is still
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ jobs:
|
|||||||
INVENTREE_DEBUG: info
|
INVENTREE_DEBUG: info
|
||||||
INVENTREE_MEDIA_ROOT: ./media
|
INVENTREE_MEDIA_ROOT: ./media
|
||||||
INVENTREE_STATIC_ROOT: ./static
|
INVENTREE_STATIC_ROOT: ./static
|
||||||
|
INVENTREE_BACKUP_DIR: ./backup
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Set up Python 3.9
|
- name: Set up Python 3.9
|
||||||
uses: actions/setup-python@152ba7c4dd6521b8e9c93f72d362ce03bf6c4f20 # pin@v1
|
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: 3.9
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@@ -42,7 +43,7 @@ jobs:
|
|||||||
git add "*.po"
|
git add "*.po"
|
||||||
git commit -m "updated translation base"
|
git commit -m "updated translation base"
|
||||||
- name: Push changes
|
- name: Push changes
|
||||||
uses: ad-m/github-push-action@9a46ba8d86d3171233e861a4351b1278a2805c83 # pin@master
|
uses: ad-m/github-push-action@4dcce6dea3e3c8187237fc86b7dfdc93e5aaae58 # pin@master
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
branch: l10
|
branch: l10
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
- name: Setup
|
- name: Setup
|
||||||
run: pip install -r requirements-dev.txt
|
run: pip install -r requirements-dev.txt
|
||||||
- name: Update requirements.txt
|
- name: Update requirements.txt
|
||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
- name: Update requirements-dev.txt
|
- name: Update requirements-dev.txt
|
||||||
run: pip-compile --generate-hashes --output-file=requirements-dev.txt
|
run: pip-compile --generate-hashes --output-file=requirements-dev.txt
|
||||||
requirements-dev.in -U
|
requirements-dev.in -U
|
||||||
- uses: stefanzweifel/git-auto-commit-action@49620cd3ed21ee620a48530e81dba0d139c9cb80 # pin@v4
|
- uses: stefanzweifel/git-auto-commit-action@fd157da78fa13d9383e5580d1fd1184d89554b51 # pin@v4.15.1
|
||||||
with:
|
with:
|
||||||
commit_message: "[Bot] Updated dependency"
|
commit_message: "[Bot] Updated dependency"
|
||||||
branch: dep-update
|
branch: dep-update
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ tasks:
|
|||||||
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
||||||
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
||||||
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
||||||
|
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
|
||||||
export PIP_USER='no'
|
export PIP_USER='no'
|
||||||
|
|
||||||
sudo apt install -y gettext
|
sudo apt install -y gettext
|
||||||
@@ -24,6 +25,7 @@ tasks:
|
|||||||
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
||||||
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
||||||
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
||||||
|
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
|
||||||
|
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
inv server
|
inv server
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: inventree
|
||||||
|
description: Open Source Inventory Management System
|
||||||
|
homepage: https://inventree.org
|
||||||
|
notifications: false
|
||||||
|
buildpack: https://github.com/mjmair/heroku-buildpack-python#v216-mjmair
|
||||||
|
env:
|
||||||
|
- STACK=heroku-20
|
||||||
|
- DISABLE_COLLECTSTATIC=1
|
||||||
|
- INVENTREE_DB_ENGINE=sqlite3
|
||||||
|
- INVENTREE_DB_NAME=database.sqlite3
|
||||||
|
- INVENTREE_PLUGINS_ENABLED
|
||||||
|
- INVENTREE_MEDIA_ROOT=/opt/inventree/media
|
||||||
|
- INVENTREE_STATIC_ROOT=/opt/inventree/static
|
||||||
|
- INVENTREE_BACKUP_DIR=/opt/inventree/backup
|
||||||
|
- INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
|
||||||
|
- INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
|
||||||
|
after_install: contrib/packager.io/postinstall.sh
|
||||||
|
targets:
|
||||||
|
ubuntu-20.04:
|
||||||
|
dependencies:
|
||||||
|
- curl
|
||||||
|
- python3
|
||||||
|
- python3-venv
|
||||||
|
- python3-pip
|
||||||
|
- python3-cffi
|
||||||
|
- python3-brotli
|
||||||
|
- python3-wheel
|
||||||
|
- libpango-1.0-0
|
||||||
|
- libharfbuzz0b
|
||||||
|
- libpangoft2-1.0-0
|
||||||
|
- gettext
|
||||||
|
- nginx
|
||||||
|
- jq
|
||||||
|
debian-11:
|
||||||
|
dependencies:
|
||||||
|
- curl
|
||||||
|
- python3
|
||||||
|
- python3-venv
|
||||||
|
- python3-pip
|
||||||
|
- python3-cffi
|
||||||
|
- python3-brotli
|
||||||
|
- python3-wheel
|
||||||
|
- libpango-1.0-0
|
||||||
|
- libpangoft2-1.0-0
|
||||||
|
- gettext
|
||||||
|
- nginx
|
||||||
|
- jq
|
||||||
+2
-1
@@ -31,6 +31,7 @@ ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
|
|||||||
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
|
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
|
||||||
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
||||||
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
||||||
|
ENV INVENTREE_BACKUP_DIR="${INVENTREE_DATA_DIR}/backup"
|
||||||
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
|
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
|
||||||
|
|
||||||
# InvenTree configuration files
|
# InvenTree configuration files
|
||||||
@@ -67,7 +68,7 @@ RUN apt-get install -y --no-install-recommends \
|
|||||||
# SQLite support
|
# SQLite support
|
||||||
sqlite3 \
|
sqlite3 \
|
||||||
# PostgreSQL support
|
# PostgreSQL support
|
||||||
libpq-dev \
|
libpq-dev postgresql-client \
|
||||||
# MySQL / MariaDB support
|
# MySQL / MariaDB support
|
||||||
default-libmysqlclient-dev mariadb-client && \
|
default-libmysqlclient-dev mariadb-client && \
|
||||||
apt-get autoclean && apt-get autoremove
|
apt-get autoclean && apt-get autoremove
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from django.http.response import StreamingHttpResponse
|
|||||||
|
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from plugin import registry
|
||||||
|
from plugin.models import PluginConfig
|
||||||
|
|
||||||
|
|
||||||
class UserMixin:
|
class UserMixin:
|
||||||
"""Mixin to setup a user and login for tests.
|
"""Mixin to setup a user and login for tests.
|
||||||
@@ -87,6 +90,21 @@ class UserMixin:
|
|||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
|
class PluginMixin:
|
||||||
|
"""Mixin to ensure that all plugins are loaded for tests."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Setup for plugin tests."""
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
# Load plugin configs
|
||||||
|
self.plugin_confs = PluginConfig.objects.all()
|
||||||
|
# Reload if not present
|
||||||
|
if not self.plugin_confs:
|
||||||
|
registry.reload_plugins()
|
||||||
|
self.plugin_confs = PluginConfig.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeAPITestCase(UserMixin, APITestCase):
|
class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||||
"""Base class for running InvenTree API tests."""
|
"""Base class for running InvenTree API tests."""
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 76
|
INVENTREE_API_VERSION = 77
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v77 -> 2022-10-12 : https://github.com/inventree/InvenTree/pull/3772
|
||||||
|
- Adds model permission checks for barcode assignment actions
|
||||||
|
|
||||||
v76 -> 2022-09-10 : https://github.com/inventree/InvenTree/pull/3640
|
v76 -> 2022-09-10 : https://github.com/inventree/InvenTree/pull/3640
|
||||||
- Refactor of barcode data on the API
|
- Refactor of barcode data on the API
|
||||||
- StockItem.uid renamed to StockItem.barcode_hash
|
- StockItem.uid renamed to StockItem.barcode_hash
|
||||||
|
|||||||
+21
-55
@@ -1,8 +1,10 @@
|
|||||||
"""AppConfig for inventree app."""
|
"""AppConfig for inventree app."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig, apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
@@ -23,10 +25,11 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
"""Setup background tasks and update exchange rates."""
|
"""Setup background tasks and update exchange rates."""
|
||||||
if canAppAccessDatabase():
|
if canAppAccessDatabase() or settings.TESTING_ENV:
|
||||||
|
|
||||||
self.remove_obsolete_tasks()
|
self.remove_obsolete_tasks()
|
||||||
|
|
||||||
|
self.collect_tasks()
|
||||||
self.start_background_tasks()
|
self.start_background_tasks()
|
||||||
|
|
||||||
if not isInTestMode(): # pragma: no cover
|
if not isInTestMode(): # pragma: no cover
|
||||||
@@ -54,68 +57,31 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
def start_background_tasks(self):
|
def start_background_tasks(self):
|
||||||
"""Start all background tests for InvenTree."""
|
"""Start all background tests for InvenTree."""
|
||||||
try:
|
|
||||||
from django_q.models import Schedule
|
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
|
||||||
logger.warning("Cannot start background tasks - app registry not ready")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Starting background tasks...")
|
logger.info("Starting background tasks...")
|
||||||
|
|
||||||
# Remove successful task results from the database
|
for task in InvenTree.tasks.tasks.task_list:
|
||||||
|
ref_name = f'{task.func.__module__}.{task.func.__name__}'
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.delete_successful_tasks',
|
ref_name,
|
||||||
schedule_type=Schedule.DAILY,
|
schedule_type=task.interval,
|
||||||
|
minutes=task.minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for InvenTree updates
|
logger.info("Started background tasks...")
|
||||||
InvenTree.tasks.schedule_task(
|
|
||||||
'InvenTree.tasks.check_for_updates',
|
|
||||||
schedule_type=Schedule.DAILY
|
|
||||||
)
|
|
||||||
|
|
||||||
# Heartbeat to let the server know the background worker is running
|
def collect_tasks(self):
|
||||||
InvenTree.tasks.schedule_task(
|
"""Collect all background tasks."""
|
||||||
'InvenTree.tasks.heartbeat',
|
|
||||||
schedule_type=Schedule.MINUTES,
|
|
||||||
minutes=15
|
|
||||||
)
|
|
||||||
|
|
||||||
# Keep exchange rates up to date
|
for app_name, app in apps.app_configs.items():
|
||||||
InvenTree.tasks.schedule_task(
|
if app_name == 'InvenTree':
|
||||||
'InvenTree.tasks.update_exchange_rates',
|
continue
|
||||||
schedule_type=Schedule.DAILY,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete old error messages
|
if Path(app.path).joinpath('tasks.py').exists():
|
||||||
InvenTree.tasks.schedule_task(
|
try:
|
||||||
'InvenTree.tasks.delete_old_error_logs',
|
import_module(f'{app.module.__package__}.tasks')
|
||||||
schedule_type=Schedule.DAILY,
|
except Exception as e: # pragma: no cover
|
||||||
)
|
logger.error(f"Error loading tasks for {app_name}: {e}")
|
||||||
|
|
||||||
# Delete old notification records
|
|
||||||
InvenTree.tasks.schedule_task(
|
|
||||||
'common.tasks.delete_old_notifications',
|
|
||||||
schedule_type=Schedule.DAILY,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for overdue purchase orders
|
|
||||||
InvenTree.tasks.schedule_task(
|
|
||||||
'order.tasks.check_overdue_purchase_orders',
|
|
||||||
schedule_type=Schedule.DAILY
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for overdue sales orders
|
|
||||||
InvenTree.tasks.schedule_task(
|
|
||||||
'order.tasks.check_overdue_sales_orders',
|
|
||||||
schedule_type=Schedule.DAILY,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for overdue build orders
|
|
||||||
InvenTree.tasks.schedule_task(
|
|
||||||
'build.tasks.check_overdue_build_orders',
|
|
||||||
schedule_type=Schedule.DAILY
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_exchange_rates(self): # pragma: no cover
|
def update_exchange_rates(self): # pragma: no cover
|
||||||
"""Update exchange rates each time the server is started.
|
"""Update exchange rates each time the server is started.
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ def get_base_dir() -> Path:
|
|||||||
return Path(__file__).parent.parent.resolve()
|
return Path(__file__).parent.parent.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Path) -> None:
|
||||||
|
"""Ensure that a directory exists.
|
||||||
|
|
||||||
|
If it does not exist, create it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def get_config_file(create=True) -> Path:
|
def get_config_file(create=True) -> Path:
|
||||||
"""Returns the path of the InvenTree configuration file.
|
"""Returns the path of the InvenTree configuration file.
|
||||||
|
|
||||||
@@ -39,6 +49,7 @@ def get_config_file(create=True) -> Path:
|
|||||||
|
|
||||||
if not cfg_filename.exists() and create:
|
if not cfg_filename.exists() and create:
|
||||||
print("InvenTree configuration file 'config.yaml' not found - creating default file")
|
print("InvenTree configuration file 'config.yaml' not found - creating default file")
|
||||||
|
ensure_dir(cfg_filename.parent)
|
||||||
|
|
||||||
cfg_template = base_dir.joinpath("config_template.yaml")
|
cfg_template = base_dir.joinpath("config_template.yaml")
|
||||||
shutil.copyfile(cfg_template, cfg_filename)
|
shutil.copyfile(cfg_template, cfg_filename)
|
||||||
@@ -149,6 +160,22 @@ def get_static_dir(create=True):
|
|||||||
return sd
|
return sd
|
||||||
|
|
||||||
|
|
||||||
|
def get_backup_dir(create=True):
|
||||||
|
"""Return the absolute path for the backup directory"""
|
||||||
|
|
||||||
|
bd = get_setting('INVENTREE_BACKUP_DIR', 'backup_dir')
|
||||||
|
|
||||||
|
if not bd:
|
||||||
|
raise FileNotFoundError('INVENTREE_BACKUP_DIR not specified')
|
||||||
|
|
||||||
|
bd = Path(bd).resolve()
|
||||||
|
|
||||||
|
if create:
|
||||||
|
bd.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return bd
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_file():
|
def get_plugin_file():
|
||||||
"""Returns the path of the InvenTree plugins specification file.
|
"""Returns the path of the InvenTree plugins specification file.
|
||||||
|
|
||||||
@@ -169,6 +196,7 @@ def get_plugin_file():
|
|||||||
if not plugin_file.exists():
|
if not plugin_file.exists():
|
||||||
logger.warning("Plugin configuration file does not exist - creating default file")
|
logger.warning("Plugin configuration file does not exist - creating default file")
|
||||||
logger.info(f"Creating plugin file at '{plugin_file}'")
|
logger.info(f"Creating plugin file at '{plugin_file}'")
|
||||||
|
ensure_dir(plugin_file.parent)
|
||||||
|
|
||||||
# If opening the file fails (no write permission, for example), then this will throw an error
|
# If opening the file fails (no write permission, for example), then this will throw an error
|
||||||
plugin_file.write_text("# InvenTree Plugins (uses PIP framework to install)\n\n")
|
plugin_file.write_text("# InvenTree Plugins (uses PIP framework to install)\n\n")
|
||||||
@@ -201,6 +229,7 @@ def get_secret_key():
|
|||||||
|
|
||||||
if not secret_key_file.exists():
|
if not secret_key_file.exists():
|
||||||
logger.info(f"Generating random key file at '{secret_key_file}'")
|
logger.info(f"Generating random key file at '{secret_key_file}'")
|
||||||
|
ensure_dir(secret_key_file.parent)
|
||||||
|
|
||||||
# Create a random key file
|
# Create a random key file
|
||||||
options = string.digits + string.ascii_letters + string.punctuation
|
options = string.digits + string.ascii_letters + string.punctuation
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import sys
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core import validators
|
|
||||||
from django.db import models as models
|
from django.db import models as models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ from rest_framework.fields import URLField as RestURLField
|
|||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
|
|
||||||
from .validators import allowable_url_schemes
|
from .validators import AllowedURLValidator, allowable_url_schemes
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeRestURLField(RestURLField):
|
class InvenTreeRestURLField(RestURLField):
|
||||||
@@ -34,7 +33,7 @@ class InvenTreeRestURLField(RestURLField):
|
|||||||
class InvenTreeURLField(models.URLField):
|
class InvenTreeURLField(models.URLField):
|
||||||
"""Custom URL field which has custom scheme validators."""
|
"""Custom URL field which has custom scheme validators."""
|
||||||
|
|
||||||
default_validators = [validators.URLValidator(schemes=allowable_url_schemes())]
|
default_validators = [AllowedURLValidator()]
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
"""Initialization method for InvenTreeURLField"""
|
"""Initialization method for InvenTreeURLField"""
|
||||||
|
|||||||
+166
-91
@@ -342,7 +342,7 @@ def normalize(d):
|
|||||||
return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()
|
return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()
|
||||||
|
|
||||||
|
|
||||||
def increment(n):
|
def increment(value):
|
||||||
"""Attempt to increment an integer (or a string that looks like an integer).
|
"""Attempt to increment an integer (or a string that looks like an integer).
|
||||||
|
|
||||||
e.g.
|
e.g.
|
||||||
@@ -351,12 +351,14 @@ def increment(n):
|
|||||||
2 -> 3
|
2 -> 3
|
||||||
AB01 -> AB02
|
AB01 -> AB02
|
||||||
QQQ -> QQQ
|
QQQ -> QQQ
|
||||||
|
|
||||||
"""
|
"""
|
||||||
value = str(n).strip()
|
value = str(value).strip()
|
||||||
|
|
||||||
# Ignore empty strings
|
# Ignore empty strings
|
||||||
if not value:
|
if value in ['', None]:
|
||||||
return value
|
# Provide a default value if provided with a null input
|
||||||
|
return '1'
|
||||||
|
|
||||||
pattern = r"(.*?)(\d+)?$"
|
pattern = r"(.*?)(\d+)?$"
|
||||||
|
|
||||||
@@ -542,138 +544,211 @@ def DownloadFile(data, filename, content_type='application/text', inline=False)
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
def increment_serial_number(serial: str):
|
||||||
"""Attempt to extract serial numbers from an input string.
|
"""Given a serial number, (attempt to) generate the *next* serial number.
|
||||||
|
|
||||||
Requirements:
|
Note: This method is exposed to custom plugins.
|
||||||
- Serial numbers can be either strings, or integers
|
|
||||||
- Serial numbers can be split by whitespace / newline / commma chars
|
Arguments:
|
||||||
- Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
|
serial: The serial number which should be incremented
|
||||||
- Serial numbers can be defined as ~ for getting the next available serial number
|
|
||||||
|
Returns:
|
||||||
|
incremented value, or None if incrementing could not be performed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
# Ensure we start with a string value
|
||||||
|
if serial is not None:
|
||||||
|
serial = str(serial).strip()
|
||||||
|
|
||||||
|
# First, let any plugins attempt to increment the serial number
|
||||||
|
for plugin in registry.with_mixin('validation'):
|
||||||
|
result = plugin.increment_serial_number(serial)
|
||||||
|
if result is not None:
|
||||||
|
return str(result)
|
||||||
|
|
||||||
|
# If we get to here, no plugins were able to "increment" the provided serial value
|
||||||
|
# Attempt to perform increment according to some basic rules
|
||||||
|
return increment(serial)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_serial_numbers(input_string, expected_quantity: int, starting_value=None):
|
||||||
|
"""Extract a list of serial numbers from a provided input string.
|
||||||
|
|
||||||
|
The input string can be specified using the following concepts:
|
||||||
|
|
||||||
|
- Individual serials are separated by comma: 1, 2, 3, 6,22
|
||||||
|
- Sequential ranges with provided limits are separated by hyphens: 1-5, 20 - 40
|
||||||
|
- The "next" available serial number can be specified with the tilde (~) character
|
||||||
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
|
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
|
||||||
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
|
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
|
||||||
|
|
||||||
Args:
|
Actual generation of sequential serials is passed to the 'validation' plugin mixin,
|
||||||
serials: input string with patterns
|
allowing custom plugins to determine how serial values are incremented.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
input_string: Input string with specified serial numbers (string, or integer)
|
||||||
expected_quantity: The number of (unique) serial numbers we expect
|
expected_quantity: The number of (unique) serial numbers we expect
|
||||||
next_number(int): the next possible serial number
|
starting_value: Provide a starting value for the sequence (or None)
|
||||||
"""
|
"""
|
||||||
serials = serials.strip()
|
|
||||||
|
|
||||||
# fill in the next serial number into the serial
|
if starting_value is None:
|
||||||
while '~' in serials:
|
starting_value = increment_serial_number(None)
|
||||||
serials = serials.replace('~', str(next_number), 1)
|
|
||||||
next_number += 1
|
|
||||||
|
|
||||||
# Split input string by whitespace or comma (,) characters
|
|
||||||
groups = re.split(r"[\s,]+", serials)
|
|
||||||
|
|
||||||
numbers = []
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
# Helper function to check for duplicated numbers
|
|
||||||
def add_sn(sn):
|
|
||||||
# Attempt integer conversion first, so numerical strings are never stored
|
|
||||||
try:
|
|
||||||
sn = int(sn)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if sn in numbers:
|
|
||||||
errors.append(_('Duplicate serial: {sn}').format(sn=sn))
|
|
||||||
else:
|
|
||||||
numbers.append(sn)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
expected_quantity = int(expected_quantity)
|
expected_quantity = int(expected_quantity)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError([_("Invalid quantity provided")])
|
raise ValidationError([_("Invalid quantity provided")])
|
||||||
|
|
||||||
if len(serials) == 0:
|
if input_string:
|
||||||
|
input_string = str(input_string).strip()
|
||||||
|
else:
|
||||||
|
input_string = ''
|
||||||
|
|
||||||
|
if len(input_string) == 0:
|
||||||
raise ValidationError([_("Empty serial number string")])
|
raise ValidationError([_("Empty serial number string")])
|
||||||
|
|
||||||
# If the user has supplied the correct number of serials, don't process them for groups
|
next_value = increment_serial_number(starting_value)
|
||||||
# just add them so any duplicates (or future validations) are checked
|
|
||||||
|
# Substitute ~ character with latest value
|
||||||
|
while '~' in input_string and next_value:
|
||||||
|
input_string = input_string.replace('~', str(next_value), 1)
|
||||||
|
next_value = increment_serial_number(next_value)
|
||||||
|
|
||||||
|
# Split input string by whitespace or comma (,) characters
|
||||||
|
groups = re.split(r"[\s,]+", input_string)
|
||||||
|
|
||||||
|
serials = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def add_error(error: str):
|
||||||
|
"""Helper function for adding an error message"""
|
||||||
|
if error not in errors:
|
||||||
|
errors.append(error)
|
||||||
|
|
||||||
|
def add_serial(serial):
|
||||||
|
"""Helper function to check for duplicated values"""
|
||||||
|
if serial in serials:
|
||||||
|
add_error(_("Duplicate serial") + f": {serial}")
|
||||||
|
else:
|
||||||
|
serials.append(serial)
|
||||||
|
|
||||||
|
# If the user has supplied the correct number of serials, do not split into groups
|
||||||
if len(groups) == expected_quantity:
|
if len(groups) == expected_quantity:
|
||||||
for group in groups:
|
for group in groups:
|
||||||
add_sn(group)
|
add_serial(group)
|
||||||
|
|
||||||
if len(errors) > 0:
|
if len(errors) > 0:
|
||||||
raise ValidationError(errors)
|
raise ValidationError(errors)
|
||||||
|
else:
|
||||||
return numbers
|
return serials
|
||||||
|
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group = group.strip()
|
group = group.strip()
|
||||||
|
|
||||||
# Hyphen indicates a range of numbers
|
|
||||||
if '-' in group:
|
if '-' in group:
|
||||||
|
"""Hyphen indicates a range of values:
|
||||||
|
e.g. 10-20
|
||||||
|
"""
|
||||||
items = group.split('-')
|
items = group.split('-')
|
||||||
|
|
||||||
if len(items) == 2 and all([i.isnumeric() for i in items]):
|
if len(items) == 2:
|
||||||
a = items[0].strip()
|
a = items[0]
|
||||||
b = items[1].strip()
|
b = items[1]
|
||||||
|
|
||||||
try:
|
if a == b:
|
||||||
a = int(a)
|
# Invalid group
|
||||||
b = int(b)
|
add_error(_("Invalid group range: {g}").format(g=group))
|
||||||
|
|
||||||
if a < b:
|
|
||||||
for n in range(a, b + 1):
|
|
||||||
add_sn(n)
|
|
||||||
else:
|
|
||||||
errors.append(_("Invalid group range: {g}").format(g=group))
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
errors.append(_("Invalid group: {g}").format(g=group))
|
|
||||||
continue
|
continue
|
||||||
else:
|
|
||||||
# More than 2 hyphens or non-numeric group so add without interpolating
|
|
||||||
add_sn(group)
|
|
||||||
|
|
||||||
# plus signals either
|
group_items = []
|
||||||
# 1: 'start+': expected number of serials, starting at start
|
|
||||||
# 2: 'start+number': number of serials, starting at start
|
count = 0
|
||||||
|
|
||||||
|
a_next = a
|
||||||
|
|
||||||
|
while a_next is not None and a_next not in group_items:
|
||||||
|
group_items.append(a_next)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Progress to the 'next' sequential value
|
||||||
|
a_next = str(increment_serial_number(a_next))
|
||||||
|
|
||||||
|
if a_next == b:
|
||||||
|
# Successfully got to the end of the range
|
||||||
|
group_items.append(b)
|
||||||
|
break
|
||||||
|
|
||||||
|
elif count > expected_quantity:
|
||||||
|
# More than the allowed number of items
|
||||||
|
break
|
||||||
|
|
||||||
|
elif a_next is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(group_items) > 0 and group_items[0] == a and group_items[-1] == b:
|
||||||
|
# In this case, the range extraction looks like it has worked
|
||||||
|
for item in group_items:
|
||||||
|
add_serial(item)
|
||||||
|
else:
|
||||||
|
add_serial(group)
|
||||||
|
# add_error(_("Invalid group range: {g}").format(g=group))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# In the case of a different number of hyphens, simply add the entire group
|
||||||
|
add_serial(group)
|
||||||
|
|
||||||
elif '+' in group:
|
elif '+' in group:
|
||||||
|
"""Plus character (+) indicates either:
|
||||||
|
- <start>+ - Expected number of serials, beginning at the specified 'start' character
|
||||||
|
- <start>+<num> - Specified number of serials, beginning at the specified 'start' character
|
||||||
|
"""
|
||||||
items = group.split('+')
|
items = group.split('+')
|
||||||
|
|
||||||
# case 1, 2
|
sequence_items = []
|
||||||
if len(items) == 2:
|
counter = 0
|
||||||
start = int(items[0])
|
sequence_count = max(0, expected_quantity - len(serials))
|
||||||
|
|
||||||
# case 2
|
if len(items) > 2 or len(items) == 0:
|
||||||
if bool(items[1]):
|
add_error(_("Invalid group sequence: {g}").format(g=group))
|
||||||
end = start + int(items[1]) + 1
|
continue
|
||||||
|
elif len(items) == 2:
|
||||||
|
try:
|
||||||
|
if items[1] not in ['', None]:
|
||||||
|
sequence_count = int(items[1]) + 1
|
||||||
|
except ValueError:
|
||||||
|
add_error(_("Invalid group sequence: {g}").format(g=group))
|
||||||
|
continue
|
||||||
|
|
||||||
# case 1
|
value = items[0]
|
||||||
|
|
||||||
|
# Keep incrementing up to the specified quantity
|
||||||
|
while value is not None and value not in sequence_items and counter < sequence_count:
|
||||||
|
sequence_items.append(value)
|
||||||
|
value = increment_serial_number(value)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
if len(sequence_items) == sequence_count:
|
||||||
|
for item in sequence_items:
|
||||||
|
add_serial(item)
|
||||||
else:
|
else:
|
||||||
end = start + (expected_quantity - len(numbers))
|
add_error(_("Invalid group sequence: {g}").format(g=group))
|
||||||
|
|
||||||
for n in range(start, end):
|
|
||||||
add_sn(n)
|
|
||||||
# no case
|
|
||||||
else:
|
else:
|
||||||
errors.append(_("Invalid group sequence: {g}").format(g=group))
|
# At this point, we assume that the 'group' is just a single serial value
|
||||||
|
add_serial(group)
|
||||||
# At this point, we assume that the "group" is just a single serial value
|
|
||||||
elif group:
|
|
||||||
add_sn(group)
|
|
||||||
|
|
||||||
# No valid input group detected
|
|
||||||
else:
|
|
||||||
raise ValidationError(_(f"Invalid/no group {group}"))
|
|
||||||
|
|
||||||
if len(errors) > 0:
|
if len(errors) > 0:
|
||||||
raise ValidationError(errors)
|
raise ValidationError(errors)
|
||||||
|
|
||||||
if len(numbers) == 0:
|
if len(serials) == 0:
|
||||||
raise ValidationError([_("No serial numbers found")])
|
raise ValidationError([_("No serial numbers found")])
|
||||||
|
|
||||||
# The number of extracted serial numbers must match the expected quantity
|
if len(serials) != expected_quantity:
|
||||||
if expected_quantity != len(numbers):
|
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(serials), q=expected_quantity)])
|
||||||
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
|
|
||||||
|
|
||||||
return numbers
|
return serials
|
||||||
|
|
||||||
|
|
||||||
def validateFilterString(value, model=None):
|
def validateFilterString(value, model=None):
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False):
|
|||||||
'collectstatic',
|
'collectstatic',
|
||||||
'makemessages',
|
'makemessages',
|
||||||
'compilemessages',
|
'compilemessages',
|
||||||
|
'backup',
|
||||||
|
'dbbackup',
|
||||||
|
'mediabackup',
|
||||||
|
'restore',
|
||||||
|
'dbrestore',
|
||||||
|
'mediarestore',
|
||||||
]
|
]
|
||||||
|
|
||||||
if not allow_test:
|
if not allow_test:
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ STATIC_COLOR_THEMES_DIR = STATIC_ROOT.joinpath('css', 'color-themes').resolve()
|
|||||||
# Web URL endpoint for served media files
|
# Web URL endpoint for served media files
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
|
# Backup directories
|
||||||
|
DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
||||||
|
DBBACKUP_STORAGE_OPTIONS = {'location': config.get_backup_dir()}
|
||||||
|
DBBACKUP_SEND_EMAIL = False
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
@@ -176,6 +181,7 @@ INSTALLED_APPS = [
|
|||||||
'error_report', # Error reporting in the admin interface
|
'error_report', # Error reporting in the admin interface
|
||||||
'django_q',
|
'django_q',
|
||||||
'formtools', # Form wizard tools
|
'formtools', # Form wizard tools
|
||||||
|
'dbbackup', # Backups - django-dbbackup
|
||||||
|
|
||||||
'allauth', # Base app for SSO
|
'allauth', # Base app for SSO
|
||||||
'allauth.account', # Extend user with accounts
|
'allauth.account', # Extend user with accounts
|
||||||
@@ -607,6 +613,8 @@ if type(EXTRA_URL_SCHEMES) not in [list]: # pragma: no cover
|
|||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/dev/topics/i18n/
|
# https://docs.djangoproject.com/en/dev/topics/i18n/
|
||||||
LANGUAGE_CODE = get_setting('INVENTREE_LANGUAGE', 'language', 'en-us')
|
LANGUAGE_CODE = get_setting('INVENTREE_LANGUAGE', 'language', 'en-us')
|
||||||
|
# Store language settings for 30 days
|
||||||
|
LANGUAGE_COOKIE_AGE = 2592000
|
||||||
|
|
||||||
# If a new language translation is supported, it must be added here
|
# If a new language translation is supported, it must be added here
|
||||||
LANGUAGES = [
|
LANGUAGES = [
|
||||||
|
|||||||
@@ -679,6 +679,10 @@ main {
|
|||||||
color: #A94442;
|
color: #A94442;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-error-message {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.modal input {
|
.modal input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import mail as django_mail
|
from django.core import mail as django_mail
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
|
from django.core.management import call_command
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@@ -125,6 +128,79 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
|
|||||||
_func(*args, **kwargs)
|
_func(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass()
|
||||||
|
class ScheduledTask:
|
||||||
|
"""A scheduled task.
|
||||||
|
|
||||||
|
- interval: The interval at which the task should be run
|
||||||
|
- minutes: The number of minutes between task runs
|
||||||
|
- func: The function to be run
|
||||||
|
"""
|
||||||
|
|
||||||
|
func: Callable
|
||||||
|
interval: str
|
||||||
|
minutes: int = None
|
||||||
|
|
||||||
|
MINUTES = "I"
|
||||||
|
HOURLY = "H"
|
||||||
|
DAILY = "D"
|
||||||
|
WEEKLY = "W"
|
||||||
|
MONTHLY = "M"
|
||||||
|
QUARTERLY = "Q"
|
||||||
|
YEARLY = "Y"
|
||||||
|
TYPE = [MINUTES, HOURLY, DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRegister:
|
||||||
|
"""Registery for periodicall tasks."""
|
||||||
|
task_list: list[ScheduledTask] = []
|
||||||
|
|
||||||
|
def register(self, task, schedule, minutes: int = None):
|
||||||
|
"""Register a task with the que."""
|
||||||
|
self.task_list.append(ScheduledTask(task, schedule, minutes))
|
||||||
|
|
||||||
|
|
||||||
|
tasks = TaskRegister()
|
||||||
|
|
||||||
|
|
||||||
|
def scheduled_task(interval: str, minutes: int = None, tasklist: TaskRegister = None):
|
||||||
|
"""Register the given task as a scheduled task.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@register(ScheduledTask.DAILY)
|
||||||
|
def my_custom_funciton():
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interval (str): The interval at which the task should be run
|
||||||
|
minutes (int, optional): The number of minutes between task runs. Defaults to None.
|
||||||
|
tasklist (TaskRegister, optional): The list the tasks should be registered to. Defaults to None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If decorated object is not callable
|
||||||
|
ValueError: If interval is not valid
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
_type_: _description_
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _task_wrapper(admin_class):
|
||||||
|
if not isinstance(admin_class, Callable):
|
||||||
|
raise ValueError('Wrapped object must be a function')
|
||||||
|
|
||||||
|
if interval not in ScheduledTask.TYPE:
|
||||||
|
raise ValueError(f'Invalid interval. Must be one of {ScheduledTask.TYPE}')
|
||||||
|
|
||||||
|
_tasks = tasklist if tasklist else tasks
|
||||||
|
_tasks.register(admin_class, interval, minutes=minutes)
|
||||||
|
|
||||||
|
return admin_class
|
||||||
|
return _task_wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.MINUTES, 15)
|
||||||
def heartbeat():
|
def heartbeat():
|
||||||
"""Simple task which runs at 5 minute intervals, so we can determine that the background worker is actually running.
|
"""Simple task which runs at 5 minute intervals, so we can determine that the background worker is actually running.
|
||||||
|
|
||||||
@@ -148,6 +224,7 @@ def heartbeat():
|
|||||||
heartbeats.delete()
|
heartbeats.delete()
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def delete_successful_tasks():
|
def delete_successful_tasks():
|
||||||
"""Delete successful task logs which are more than a month old."""
|
"""Delete successful task logs which are more than a month old."""
|
||||||
try:
|
try:
|
||||||
@@ -167,6 +244,7 @@ def delete_successful_tasks():
|
|||||||
results.delete()
|
results.delete()
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def delete_old_error_logs():
|
def delete_old_error_logs():
|
||||||
"""Delete old error logs from the server."""
|
"""Delete old error logs from the server."""
|
||||||
try:
|
try:
|
||||||
@@ -189,6 +267,7 @@ def delete_old_error_logs():
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def check_for_updates():
|
def check_for_updates():
|
||||||
"""Check if there is an update for InvenTree."""
|
"""Check if there is an update for InvenTree."""
|
||||||
try:
|
try:
|
||||||
@@ -231,6 +310,7 @@ def check_for_updates():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def update_exchange_rates():
|
def update_exchange_rates():
|
||||||
"""Update currency exchange rates."""
|
"""Update currency exchange rates."""
|
||||||
try:
|
try:
|
||||||
@@ -272,6 +352,16 @@ def update_exchange_rates():
|
|||||||
logger.error(f"Error updating exchange rates: {e}")
|
logger.error(f"Error updating exchange rates: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
|
def run_backup():
|
||||||
|
"""Run the backup command."""
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
if InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE'):
|
||||||
|
call_command("dbbackup", noinput=True, clean=True, compress=True, interactive=False)
|
||||||
|
call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False)
|
||||||
|
|
||||||
|
|
||||||
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||||
"""Send an email with the specified subject and body, to the specified recipients list."""
|
"""Send an email with the specified subject and body, to the specified recipients list."""
|
||||||
if type(recipients) == str:
|
if type(recipients) == str:
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ class ValidatorTest(TestCase):
|
|||||||
"""Test part name validator."""
|
"""Test part name validator."""
|
||||||
validate_part_name('hello world')
|
validate_part_name('hello world')
|
||||||
|
|
||||||
|
# Validate with some strange chars
|
||||||
with self.assertRaises(django_exceptions.ValidationError):
|
with self.assertRaises(django_exceptions.ValidationError):
|
||||||
validate_part_name('This | name is not } valid')
|
validate_part_name('### <> This | name is not } valid')
|
||||||
|
|
||||||
def test_overage(self):
|
def test_overage(self):
|
||||||
"""Test overage validator."""
|
"""Test overage validator."""
|
||||||
@@ -309,7 +310,7 @@ class TestIncrement(TestCase):
|
|||||||
def tests(self):
|
def tests(self):
|
||||||
"""Test 'intelligent' incrementing function."""
|
"""Test 'intelligent' incrementing function."""
|
||||||
tests = [
|
tests = [
|
||||||
("", ""),
|
("", '1'),
|
||||||
(1, "2"),
|
(1, "2"),
|
||||||
("001", "002"),
|
("001", "002"),
|
||||||
("1001", "1002"),
|
("1001", "1002"),
|
||||||
@@ -418,7 +419,11 @@ class TestMPTT(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestSerialNumberExtraction(TestCase):
|
class TestSerialNumberExtraction(TestCase):
|
||||||
"""Tests for serial number extraction code."""
|
"""Tests for serial number extraction code.
|
||||||
|
|
||||||
|
Note that while serial number extraction is made available to custom plugins,
|
||||||
|
only simple integer-based extraction is tested here.
|
||||||
|
"""
|
||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
"""Test simple serial numbers."""
|
"""Test simple serial numbers."""
|
||||||
@@ -427,7 +432,7 @@ class TestSerialNumberExtraction(TestCase):
|
|||||||
sn = e("1-5", 5, 1)
|
sn = e("1-5", 5, 1)
|
||||||
self.assertEqual(len(sn), 5, 1)
|
self.assertEqual(len(sn), 5, 1)
|
||||||
for i in range(1, 6):
|
for i in range(1, 6):
|
||||||
self.assertIn(i, sn)
|
self.assertIn(str(i), sn)
|
||||||
|
|
||||||
sn = e("1, 2, 3, 4, 5", 5, 1)
|
sn = e("1, 2, 3, 4, 5", 5, 1)
|
||||||
self.assertEqual(len(sn), 5)
|
self.assertEqual(len(sn), 5)
|
||||||
@@ -435,55 +440,55 @@ class TestSerialNumberExtraction(TestCase):
|
|||||||
# Test partially specifying serials
|
# Test partially specifying serials
|
||||||
sn = e("1, 2, 4+", 5, 1)
|
sn = e("1, 2, 4+", 5, 1)
|
||||||
self.assertEqual(len(sn), 5)
|
self.assertEqual(len(sn), 5)
|
||||||
self.assertEqual(sn, [1, 2, 4, 5, 6])
|
self.assertEqual(sn, ['1', '2', '4', '5', '6'])
|
||||||
|
|
||||||
# Test groups are not interpolated if enough serials are supplied
|
# Test groups are not interpolated if enough serials are supplied
|
||||||
sn = e("1, 2, 3, AF5-69H, 5", 5, 1)
|
sn = e("1, 2, 3, AF5-69H, 5", 5, 1)
|
||||||
self.assertEqual(len(sn), 5)
|
self.assertEqual(len(sn), 5)
|
||||||
self.assertEqual(sn, [1, 2, 3, "AF5-69H", 5])
|
self.assertEqual(sn, ['1', '2', '3', 'AF5-69H', '5'])
|
||||||
|
|
||||||
# Test groups are not interpolated with more than one hyphen in a word
|
# Test groups are not interpolated with more than one hyphen in a word
|
||||||
sn = e("1, 2, TG-4SR-92, 4+", 5, 1)
|
sn = e("1, 2, TG-4SR-92, 4+", 5, 1)
|
||||||
self.assertEqual(len(sn), 5)
|
self.assertEqual(len(sn), 5)
|
||||||
self.assertEqual(sn, [1, 2, "TG-4SR-92", 4, 5])
|
self.assertEqual(sn, ['1', '2', "TG-4SR-92", '4', '5'])
|
||||||
|
|
||||||
# Test groups are not interpolated with alpha characters
|
# Test groups are not interpolated with alpha characters
|
||||||
sn = e("1, A-2, 3+", 5, 1)
|
sn = e("1, A-2, 3+", 5, 1)
|
||||||
self.assertEqual(len(sn), 5)
|
self.assertEqual(len(sn), 5)
|
||||||
self.assertEqual(sn, [1, "A-2", 3, 4, 5])
|
self.assertEqual(sn, ['1', "A-2", '3', '4', '5'])
|
||||||
|
|
||||||
# Test multiple placeholders
|
# Test multiple placeholders
|
||||||
sn = e("1 2 ~ ~ ~", 5, 3)
|
sn = e("1 2 ~ ~ ~", 5, 2)
|
||||||
self.assertEqual(len(sn), 5)
|
self.assertEqual(len(sn), 5)
|
||||||
self.assertEqual(sn, [1, 2, 3, 4, 5])
|
self.assertEqual(sn, ['1', '2', '3', '4', '5'])
|
||||||
|
|
||||||
sn = e("1-5, 10-15", 11, 1)
|
sn = e("1-5, 10-15", 11, 1)
|
||||||
self.assertIn(3, sn)
|
self.assertIn('3', sn)
|
||||||
self.assertIn(13, sn)
|
self.assertIn('13', sn)
|
||||||
|
|
||||||
sn = e("1+", 10, 1)
|
sn = e("1+", 10, 1)
|
||||||
self.assertEqual(len(sn), 10)
|
self.assertEqual(len(sn), 10)
|
||||||
self.assertEqual(sn, [_ for _ in range(1, 11)])
|
self.assertEqual(sn, [str(_) for _ in range(1, 11)])
|
||||||
|
|
||||||
sn = e("4, 1+2", 4, 1)
|
sn = e("4, 1+2", 4, 1)
|
||||||
self.assertEqual(len(sn), 4)
|
self.assertEqual(len(sn), 4)
|
||||||
self.assertEqual(sn, [4, 1, 2, 3])
|
self.assertEqual(sn, ['4', '1', '2', '3'])
|
||||||
|
|
||||||
sn = e("~", 1, 1)
|
sn = e("~", 1, 1)
|
||||||
self.assertEqual(len(sn), 1)
|
self.assertEqual(len(sn), 1)
|
||||||
self.assertEqual(sn, [1])
|
self.assertEqual(sn, ['2'])
|
||||||
|
|
||||||
sn = e("~", 1, 3)
|
sn = e("~", 1, 3)
|
||||||
self.assertEqual(len(sn), 1)
|
self.assertEqual(len(sn), 1)
|
||||||
self.assertEqual(sn, [3])
|
self.assertEqual(sn, ['4'])
|
||||||
|
|
||||||
sn = e("~+", 2, 5)
|
sn = e("~+", 2, 4)
|
||||||
self.assertEqual(len(sn), 2)
|
self.assertEqual(len(sn), 2)
|
||||||
self.assertEqual(sn, [5, 6])
|
self.assertEqual(sn, ['5', '6'])
|
||||||
|
|
||||||
sn = e("~+3", 4, 5)
|
sn = e("~+3", 4, 4)
|
||||||
self.assertEqual(len(sn), 4)
|
self.assertEqual(len(sn), 4)
|
||||||
self.assertEqual(sn, [5, 6, 7, 8])
|
self.assertEqual(sn, ['5', '6', '7', '8'])
|
||||||
|
|
||||||
def test_failures(self):
|
def test_failures(self):
|
||||||
"""Test wron serial numbers."""
|
"""Test wron serial numbers."""
|
||||||
@@ -522,19 +527,19 @@ class TestSerialNumberExtraction(TestCase):
|
|||||||
|
|
||||||
sn = e("1 3-5 9+2", 7, 1)
|
sn = e("1 3-5 9+2", 7, 1)
|
||||||
self.assertEqual(len(sn), 7)
|
self.assertEqual(len(sn), 7)
|
||||||
self.assertEqual(sn, [1, 3, 4, 5, 9, 10, 11])
|
self.assertEqual(sn, ['1', '3', '4', '5', '9', '10', '11'])
|
||||||
|
|
||||||
sn = e("1,3-5,9+2", 7, 1)
|
sn = e("1,3-5,9+2", 7, 1)
|
||||||
self.assertEqual(len(sn), 7)
|
self.assertEqual(len(sn), 7)
|
||||||
self.assertEqual(sn, [1, 3, 4, 5, 9, 10, 11])
|
self.assertEqual(sn, ['1', '3', '4', '5', '9', '10', '11'])
|
||||||
|
|
||||||
sn = e("~+2", 3, 14)
|
sn = e("~+2", 3, 13)
|
||||||
self.assertEqual(len(sn), 3)
|
self.assertEqual(len(sn), 3)
|
||||||
self.assertEqual(sn, [14, 15, 16])
|
self.assertEqual(sn, ['14', '15', '16'])
|
||||||
|
|
||||||
sn = e("~+", 2, 14)
|
sn = e("~+", 2, 13)
|
||||||
self.assertEqual(len(sn), 2)
|
self.assertEqual(len(sn), 2)
|
||||||
self.assertEqual(sn, [14, 15])
|
self.assertEqual(sn, ['14', '15'])
|
||||||
|
|
||||||
|
|
||||||
class TestVersionNumber(TestCase):
|
class TestVersionNumber(TestCase):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import re
|
|||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core import validators
|
||||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -37,17 +38,49 @@ def allowable_url_schemes():
|
|||||||
return schemes
|
return schemes
|
||||||
|
|
||||||
|
|
||||||
|
class AllowedURLValidator(validators.URLValidator):
|
||||||
|
"""Custom URL validator to allow for custom schemes."""
|
||||||
|
def __call__(self, value):
|
||||||
|
"""Validate the URL."""
|
||||||
|
self.schemes = allowable_url_schemes()
|
||||||
|
super().__call__(value)
|
||||||
|
|
||||||
|
|
||||||
def validate_part_name(value):
|
def validate_part_name(value):
|
||||||
"""Prevent some illegal characters in part names."""
|
"""Validate the name field for a Part instance
|
||||||
for c in ['|', '#', '$', '{', '}']:
|
|
||||||
if c in str(value):
|
This function is exposed to any Validation plugins, and thus can be customized.
|
||||||
raise ValidationError(
|
"""
|
||||||
_('Invalid character in part name')
|
|
||||||
)
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
for plugin in registry.with_mixin('validation'):
|
||||||
|
# Run the name through each custom validator
|
||||||
|
# If the plugin returns 'True' we will skip any subsequent validation
|
||||||
|
if plugin.validate_part_name(value):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def validate_part_ipn(value):
|
def validate_part_ipn(value):
|
||||||
"""Validate the Part IPN against regex rule."""
|
"""Validate the IPN field for a Part instance.
|
||||||
|
|
||||||
|
This function is exposed to any Validation plugins, and thus can be customized.
|
||||||
|
|
||||||
|
If no validation errors are raised, the IPN is also validated against a configurable regex pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
plugins = registry.with_mixin('validation')
|
||||||
|
|
||||||
|
for plugin in plugins:
|
||||||
|
# Run the IPN through each custom validator
|
||||||
|
# If the plugin returns 'True' we will skip any subsequent validation
|
||||||
|
if plugin.validate_part_ipn(value):
|
||||||
|
return
|
||||||
|
|
||||||
|
# If we get to here, none of the plugins have raised an error
|
||||||
|
|
||||||
pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX')
|
pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX')
|
||||||
|
|
||||||
if pattern:
|
if pattern:
|
||||||
@@ -59,28 +92,25 @@ def validate_part_ipn(value):
|
|||||||
|
|
||||||
def validate_purchase_order_reference(value):
|
def validate_purchase_order_reference(value):
|
||||||
"""Validate the 'reference' field of a PurchaseOrder."""
|
"""Validate the 'reference' field of a PurchaseOrder."""
|
||||||
pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX')
|
|
||||||
|
|
||||||
if pattern:
|
from order.models import PurchaseOrder
|
||||||
match = re.search(pattern, value)
|
|
||||||
|
|
||||||
if match is None:
|
# If we get to here, run the "default" validation routine
|
||||||
raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern))
|
PurchaseOrder.validate_reference_field(value)
|
||||||
|
|
||||||
|
|
||||||
def validate_sales_order_reference(value):
|
def validate_sales_order_reference(value):
|
||||||
"""Validate the 'reference' field of a SalesOrder."""
|
"""Validate the 'reference' field of a SalesOrder."""
|
||||||
pattern = common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_REGEX')
|
|
||||||
|
|
||||||
if pattern:
|
from order.models import SalesOrder
|
||||||
match = re.search(pattern, value)
|
|
||||||
|
|
||||||
if match is None:
|
# If we get to here, run the "default" validation routine
|
||||||
raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern))
|
SalesOrder.validate_reference_field(value)
|
||||||
|
|
||||||
|
|
||||||
def validate_tree_name(value):
|
def validate_tree_name(value):
|
||||||
"""Placeholder for legacy function used in migrations."""
|
"""Placeholder for legacy function used in migrations."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def validate_overage(value):
|
def validate_overage(value):
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS
|
|||||||
from InvenTree.serializers import UserSerializer
|
from InvenTree.serializers import UserSerializer
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
from InvenTree.helpers import extract_serial_numbers
|
|
||||||
from InvenTree.serializers import InvenTreeDecimalField
|
from InvenTree.serializers import InvenTreeDecimalField
|
||||||
from InvenTree.status_codes import StockStatus
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
@@ -260,7 +259,11 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
if serial_numbers:
|
if serial_numbers:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt())
|
self.serials = InvenTree.helpers.extract_serial_numbers(
|
||||||
|
serial_numbers,
|
||||||
|
quantity,
|
||||||
|
part.get_latest_serial_number()
|
||||||
|
)
|
||||||
except DjangoValidationError as e:
|
except DjangoValidationError as e:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'serial_numbers': e.messages,
|
'serial_numbers': e.messages,
|
||||||
@@ -270,12 +273,12 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
existing = []
|
existing = []
|
||||||
|
|
||||||
for serial in self.serials:
|
for serial in self.serials:
|
||||||
if part.checkIfSerialNumberExists(serial):
|
if not part.validate_serial_number(serial):
|
||||||
existing.append(serial)
|
existing.append(serial)
|
||||||
|
|
||||||
if len(existing) > 0:
|
if len(existing) > 0:
|
||||||
|
|
||||||
msg = _("The following serial numbers already exist")
|
msg = _("The following serial numbers already exist or are invalid")
|
||||||
msg += " : "
|
msg += " : "
|
||||||
msg += ",".join([str(e) for e in existing])
|
msg += ",".join([str(e) for e in existing])
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ def notify_overdue_build_order(bo: build.models.Build):
|
|||||||
trigger_event(event_name, build_order=bo.pk)
|
trigger_event(event_name, build_order=bo.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@InvenTree.tasks.scheduled_task(InvenTree.tasks.ScheduledTask.DAILY)
|
||||||
def check_overdue_build_orders():
|
def check_overdue_build_orders():
|
||||||
"""Check if any outstanding BuildOrders have just become overdue
|
"""Check if any outstanding BuildOrders have just become overdue
|
||||||
|
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ class BuildTest(BuildAPITest):
|
|||||||
expected_code=400,
|
expected_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn('The following serial numbers already exist : 1,2,3', str(response.data))
|
self.assertIn('The following serial numbers already exist or are invalid : 1,2,3', str(response.data))
|
||||||
|
|
||||||
# Double check no new outputs have been created
|
# Double check no new outputs have been created
|
||||||
self.assertEqual(n_outputs + 5, bo.output_count)
|
self.assertEqual(n_outputs + 5, bo.output_count)
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ def validate_build_order_reference_pattern(pattern):
|
|||||||
|
|
||||||
|
|
||||||
def validate_build_order_reference(value):
|
def validate_build_order_reference(value):
|
||||||
"""Validate that the BuildOrder reference field matches the required pattern"""
|
"""Validate that the BuildOrder reference field matches the required pattern."""
|
||||||
|
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
|
|
||||||
|
# If we get to here, run the "default" validation routine
|
||||||
Build.validate_reference_field(value)
|
Build.validate_reference_field(value)
|
||||||
|
|||||||
@@ -886,6 +886,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'INVENTREE_BACKUP_ENABLE': {
|
||||||
|
'name': _('Automatic Backup'),
|
||||||
|
'description': _('Enable automatic backup of database and media files'),
|
||||||
|
'validator': bool,
|
||||||
|
'default': True,
|
||||||
|
},
|
||||||
|
|
||||||
'BARCODE_ENABLE': {
|
'BARCODE_ENABLE': {
|
||||||
'name': _('Barcode Support'),
|
'name': _('Barcode Support'),
|
||||||
'description': _('Enable barcode scanner support'),
|
'description': _('Enable barcode scanner support'),
|
||||||
@@ -1132,6 +1139,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'SERIAL_NUMBER_GLOBALLY_UNIQUE': {
|
||||||
|
'name': _('Globally Unique Serials'),
|
||||||
|
'description': _('Serial numbers for stock items must be globally unique'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
|
||||||
'STOCK_BATCH_CODE_TEMPLATE': {
|
'STOCK_BATCH_CODE_TEMPLATE': {
|
||||||
'name': _('Batch Code Template'),
|
'name': _('Batch Code Template'),
|
||||||
'description': _('Template for generating default batch codes for stock items'),
|
'description': _('Template for generating default batch codes for stock items'),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import InvenTree.helpers
|
|||||||
from common.models import NotificationEntry, NotificationMessage
|
from common.models import NotificationEntry, NotificationMessage
|
||||||
from InvenTree.ready import isImportingData
|
from InvenTree.ready import isImportingData
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.models import NotificationUserSetting
|
from plugin.models import NotificationUserSetting, PluginConfig
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
@@ -397,6 +397,28 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
|||||||
logger.info(f"No possible users for notification '{category}'")
|
logger.info(f"No possible users for notification '{category}'")
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_superuser_notification(plugin: PluginConfig, msg: str):
|
||||||
|
"""Trigger a notification to all superusers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin (PluginConfig): Plugin that is raising the notification
|
||||||
|
msg (str): Detailed message that should be attached
|
||||||
|
"""
|
||||||
|
users = get_user_model().objects.filter(is_superuser=True)
|
||||||
|
|
||||||
|
trigger_notification(
|
||||||
|
plugin,
|
||||||
|
'inventree.plugin',
|
||||||
|
context={
|
||||||
|
'error': plugin,
|
||||||
|
'name': _('Error raised by plugin'),
|
||||||
|
'message': msg,
|
||||||
|
},
|
||||||
|
targets=users,
|
||||||
|
delivery_methods=set([UIMessageNotification]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def deliver_notification(cls: NotificationMethod, obj, category: str, targets, context: dict):
|
def deliver_notification(cls: NotificationMethod, obj, category: str, targets, context: dict):
|
||||||
"""Send notification with the provided class.
|
"""Send notification with the provided class.
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
|
|
||||||
|
from InvenTree.tasks import ScheduledTask, scheduled_task
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def delete_old_notifications():
|
def delete_old_notifications():
|
||||||
"""Remove old notifications from the database.
|
"""Remove old notifications from the database.
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ from django.core.cache import cache
|
|||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase, PluginMixin
|
||||||
from InvenTree.helpers import InvenTreeTestCase, str2bool
|
from InvenTree.helpers import InvenTreeTestCase, str2bool
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.models import NotificationUserSetting, PluginConfig
|
from plugin.models import NotificationUserSetting
|
||||||
|
|
||||||
from .api import WebhookView
|
from .api import WebhookView
|
||||||
from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting,
|
from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting,
|
||||||
@@ -540,7 +540,7 @@ class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): True')
|
self.assertEqual(str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): True')
|
||||||
|
|
||||||
|
|
||||||
class PluginSettingsApiTest(InvenTreeAPITestCase):
|
class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
|
||||||
"""Tests for the plugin settings API."""
|
"""Tests for the plugin settings API."""
|
||||||
|
|
||||||
def test_plugin_list(self):
|
def test_plugin_list(self):
|
||||||
@@ -561,11 +561,8 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
def test_valid_plugin_slug(self):
|
def test_valid_plugin_slug(self):
|
||||||
"""Test that an valid plugin slug runs through."""
|
"""Test that an valid plugin slug runs through."""
|
||||||
# load plugin configs
|
# Activate plugin
|
||||||
fixtures = PluginConfig.objects.all()
|
registry.set_plugin_state('sample', True)
|
||||||
if not fixtures:
|
|
||||||
registry.reload_plugins()
|
|
||||||
fixtures = PluginConfig.objects.all()
|
|
||||||
|
|
||||||
# get data
|
# get data
|
||||||
url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'API_KEY'})
|
url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'API_KEY'})
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
|
||||||
|
# Secret key for backend
|
||||||
|
# Use the environment variable INVENTREE_SECRET_KEY_FILE
|
||||||
|
#secret_key_file: '/etc/inventree/secret_key.txt'
|
||||||
|
|
||||||
# Database backend selection - Configure backend database settings
|
# Database backend selection - Configure backend database settings
|
||||||
# Documentation: https://inventree.readthedocs.io/en/latest/start/config/
|
# Documentation: https://inventree.readthedocs.io/en/latest/start/config/
|
||||||
|
|
||||||
@@ -22,6 +26,13 @@ database:
|
|||||||
# HOST: Database host address (if required)
|
# HOST: Database host address (if required)
|
||||||
# PORT: Database host port (if required)
|
# PORT: Database host port (if required)
|
||||||
|
|
||||||
|
# --- Database settings ---
|
||||||
|
#ENGINE: sampleengine
|
||||||
|
#NAME: '/path/to/database'
|
||||||
|
#USER: sampleuser
|
||||||
|
#PASSWORD: samplepassword
|
||||||
|
#HOST: samplehost
|
||||||
|
#PORT: sampleport
|
||||||
|
|
||||||
# --- Example Configuration - MySQL ---
|
# --- Example Configuration - MySQL ---
|
||||||
#ENGINE: mysql
|
#ENGINE: mysql
|
||||||
@@ -105,8 +116,8 @@ sentry_enabled: False
|
|||||||
# Set this variable to True to enable InvenTree Plugins
|
# Set this variable to True to enable InvenTree Plugins
|
||||||
# Alternatively, use the environment variable INVENTREE_PLUGINS_ENABLED
|
# Alternatively, use the environment variable INVENTREE_PLUGINS_ENABLED
|
||||||
plugins_enabled: False
|
plugins_enabled: False
|
||||||
#plugin_file: /path/to/plugins.txt
|
#plugin_file: '/path/to/plugins.txt'
|
||||||
#plugin_dir: /path/to/plugins/
|
#plugin_dir: '/path/to/plugins/'
|
||||||
|
|
||||||
# Allowed hosts (see ALLOWED_HOSTS in Django settings documentation)
|
# Allowed hosts (see ALLOWED_HOSTS in Django settings documentation)
|
||||||
# A list of strings representing the host/domain names that this Django site can serve.
|
# A list of strings representing the host/domain names that this Django site can serve.
|
||||||
@@ -131,6 +142,9 @@ cors:
|
|||||||
# STATIC_ROOT is the local filesystem location for storing static files
|
# STATIC_ROOT is the local filesystem location for storing static files
|
||||||
#static_root: '/home/inventree/data/static'
|
#static_root: '/home/inventree/data/static'
|
||||||
|
|
||||||
|
# BACKUP_DIR is the local filesystem location for storing backups
|
||||||
|
#backup_dir: '/home/inventree/data/backup'
|
||||||
|
|
||||||
# Background worker options
|
# Background worker options
|
||||||
background:
|
background:
|
||||||
workers: 4
|
workers: 4
|
||||||
|
|||||||
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
+1064
-1070
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
@@ -531,7 +531,11 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
|||||||
if serial_numbers:
|
if serial_numbers:
|
||||||
try:
|
try:
|
||||||
# Pass the serial numbers through to the parent serializer once validated
|
# Pass the serial numbers through to the parent serializer once validated
|
||||||
data['serials'] = extract_serial_numbers(serial_numbers, pack_quantity, base_part.getLatestSerialNumberInt())
|
data['serials'] = extract_serial_numbers(
|
||||||
|
serial_numbers,
|
||||||
|
pack_quantity,
|
||||||
|
base_part.get_latest_serial_number()
|
||||||
|
)
|
||||||
except DjangoValidationError as e:
|
except DjangoValidationError as e:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'serial_numbers': e.messages,
|
'serial_numbers': e.messages,
|
||||||
@@ -1256,7 +1260,11 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
|
|||||||
part = line_item.part
|
part = line_item.part
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data['serials'] = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt())
|
data['serials'] = extract_serial_numbers(
|
||||||
|
serial_numbers,
|
||||||
|
quantity,
|
||||||
|
part.get_latest_serial_number()
|
||||||
|
)
|
||||||
except DjangoValidationError as e:
|
except DjangoValidationError as e:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'serial_numbers': e.messages,
|
'serial_numbers': e.messages,
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
import common.notifications
|
import common.notifications
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.tasks
|
|
||||||
import order.models
|
import order.models
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||||
|
from InvenTree.tasks import ScheduledTask, scheduled_task
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ def notify_overdue_purchase_order(po: order.models.PurchaseOrder):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def check_overdue_purchase_orders():
|
def check_overdue_purchase_orders():
|
||||||
"""Check if any outstanding PurchaseOrders have just become overdue:
|
"""Check if any outstanding PurchaseOrders have just become overdue:
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ def notify_overdue_sales_order(so: order.models.SalesOrder):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def check_overdue_sales_orders():
|
def check_overdue_sales_orders():
|
||||||
"""Check if any outstanding SalesOrders have just become overdue
|
"""Check if any outstanding SalesOrders have just become overdue
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ from company.models import Company, ManufacturerPart, SupplierPart
|
|||||||
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
||||||
ListCreateDestroyAPIView)
|
ListCreateDestroyAPIView)
|
||||||
from InvenTree.filters import InvenTreeOrderingFilter
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
from InvenTree.helpers import (DownloadFile, increment, isNull, str2bool,
|
from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull,
|
||||||
str2int)
|
str2bool, str2int)
|
||||||
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI,
|
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI,
|
||||||
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
|
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
|
||||||
UpdateAPI)
|
UpdateAPI)
|
||||||
@@ -717,16 +717,16 @@ class PartSerialNumberDetail(RetrieveAPI):
|
|||||||
part = self.get_object()
|
part = self.get_object()
|
||||||
|
|
||||||
# Calculate the "latest" serial number
|
# Calculate the "latest" serial number
|
||||||
latest = part.getLatestSerialNumber()
|
latest = part.get_latest_serial_number()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'latest': latest,
|
'latest': latest,
|
||||||
}
|
}
|
||||||
|
|
||||||
if latest is not None:
|
if latest is not None:
|
||||||
next_serial = increment(latest)
|
next_serial = increment_serial_number(latest)
|
||||||
|
|
||||||
if next_serial != increment:
|
if next_serial != latest:
|
||||||
data['next'] = next_serial
|
data['next'] = next_serial
|
||||||
|
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|||||||
+96
-82
@@ -529,112 +529,126 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def checkIfSerialNumberExists(self, sn, exclude_self=False):
|
def validate_serial_number(self, serial: str, stock_item=None, check_duplicates=True, raise_error=False):
|
||||||
"""Check if a serial number exists for this Part.
|
"""Validate a serial number against this Part instance.
|
||||||
|
|
||||||
Note: Serial numbers must be unique across an entire Part "tree", so here we filter by the entire tree.
|
Note: This function is exposed to any Validation plugins, and thus can be customized.
|
||||||
|
|
||||||
|
Any plugins which implement the 'validate_serial_number' method have three possible outcomes:
|
||||||
|
|
||||||
|
- Decide the serial is objectionable and raise a django.core.exceptions.ValidationError
|
||||||
|
- Decide the serial is acceptable, and return None to proceed to other tests
|
||||||
|
- Decide the serial is acceptable, and return True to skip any further tests
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
serial: The proposed serial number
|
||||||
|
stock_item: (optional) A StockItem instance which has this serial number assigned (e.g. testing for duplicates)
|
||||||
|
raise_error: If False, and ValidationError(s) will be handled
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if serial number is 'valid' else False
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError if serial number is invalid and raise_error = True
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
serial = str(serial).strip()
|
||||||
|
|
||||||
|
# First, throw the serial number against each of the loaded validation plugins
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
try:
|
||||||
|
for plugin in registry.with_mixin('validation'):
|
||||||
|
# Run the serial number through each custom validator
|
||||||
|
# If the plugin returns 'True' we will skip any subsequent validation
|
||||||
|
if plugin.validate_serial_number(serial):
|
||||||
|
return True
|
||||||
|
except ValidationError as exc:
|
||||||
|
if raise_error:
|
||||||
|
# Re-throw the error
|
||||||
|
raise exc
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
"""
|
||||||
|
If we are here, none of the loaded plugins (if any) threw an error or exited early
|
||||||
|
|
||||||
|
Now, we run the "default" serial number validation routine,
|
||||||
|
which checks that the serial number is not duplicated
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not check_duplicates:
|
||||||
|
return
|
||||||
|
|
||||||
|
from part.models import Part
|
||||||
|
from stock.models import StockItem
|
||||||
|
|
||||||
|
if common.models.InvenTreeSetting.get_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
|
||||||
|
# Serial number must be unique across *all* parts
|
||||||
|
parts = Part.objects.all()
|
||||||
|
else:
|
||||||
|
# Serial number must only be unique across this part "tree"
|
||||||
parts = Part.objects.filter(tree_id=self.tree_id)
|
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||||
|
|
||||||
stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn)
|
stock = StockItem.objects.filter(part__in=parts, serial=serial)
|
||||||
|
|
||||||
if exclude_self:
|
if stock_item:
|
||||||
stock = stock.exclude(pk=self.pk)
|
# Exclude existing StockItem from query
|
||||||
|
stock = stock.exclude(pk=stock_item.pk)
|
||||||
|
|
||||||
return stock.exists()
|
if stock.exists():
|
||||||
|
if raise_error:
|
||||||
|
raise ValidationError(_("Stock item with this serial number already exists") + ": " + serial)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# This serial number is perfectly valid
|
||||||
|
return True
|
||||||
|
|
||||||
def find_conflicting_serial_numbers(self, serials):
|
def find_conflicting_serial_numbers(self, serials: list):
|
||||||
"""For a provided list of serials, return a list of those which are conflicting."""
|
"""For a provided list of serials, return a list of those which are conflicting."""
|
||||||
|
|
||||||
conflicts = []
|
conflicts = []
|
||||||
|
|
||||||
for serial in serials:
|
for serial in serials:
|
||||||
if self.checkIfSerialNumberExists(serial, exclude_self=True):
|
if not self.validate_serial_number(serial):
|
||||||
conflicts.append(serial)
|
conflicts.append(serial)
|
||||||
|
|
||||||
return conflicts
|
return conflicts
|
||||||
|
|
||||||
def getLatestSerialNumber(self):
|
def get_latest_serial_number(self):
|
||||||
"""Return the "latest" serial number for this Part.
|
"""Find the 'latest' serial number for this Part.
|
||||||
|
|
||||||
If *all* the serial numbers are integers, then this will return the highest one.
|
Here we attempt to find the "highest" serial number which exists for this Part.
|
||||||
Otherwise, it will simply return the serial number most recently added.
|
There are a number of edge cases where this method can fail,
|
||||||
|
but this is accepted to keep database performance at a reasonable level.
|
||||||
|
|
||||||
Note: Serial numbers must be unique across an entire Part "tree",
|
Note: Serial numbers must be unique across an entire Part "tree",
|
||||||
so we filter by the entire tree.
|
so we filter by the entire tree.
|
||||||
"""
|
|
||||||
parts = Part.objects.filter(tree_id=self.tree_id)
|
|
||||||
stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None)
|
|
||||||
|
|
||||||
# There are no matchin StockItem objects (skip further tests)
|
Returns:
|
||||||
|
The latest serial number specified for this part, or None
|
||||||
|
"""
|
||||||
|
|
||||||
|
stock = StockModels.StockItem.objects.all().exclude(serial=None).exclude(serial='')
|
||||||
|
|
||||||
|
# Generate a query for any stock items for this part variant tree with non-empty serial numbers
|
||||||
|
if common.models.InvenTreeSetting.get_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
|
||||||
|
# Serial numbers are unique across all parts
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Serial numbers are unique acros part trees
|
||||||
|
stock = stock.filter(part__tree_id=self.tree_id)
|
||||||
|
|
||||||
|
# There are no matching StockItem objects (skip further tests)
|
||||||
if not stock.exists():
|
if not stock.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Attempt to coerce the returned serial numbers to integers
|
# Sort in descending order
|
||||||
# If *any* are not integers, fail!
|
stock = stock.order_by('-serial_int', '-serial', '-pk')
|
||||||
try:
|
|
||||||
ordered = sorted(stock.all(), reverse=True, key=lambda n: int(n.serial))
|
|
||||||
|
|
||||||
if len(ordered) > 0:
|
# Return the first serial value
|
||||||
return ordered[0].serial
|
return stock[0].serial
|
||||||
|
|
||||||
# One or more of the serial numbers was non-numeric
|
|
||||||
# In this case, the "best" we can do is return the most recent
|
|
||||||
except ValueError:
|
|
||||||
return stock.last().serial
|
|
||||||
|
|
||||||
# No serial numbers found
|
|
||||||
return None
|
|
||||||
|
|
||||||
def getLatestSerialNumberInt(self):
|
|
||||||
"""Return the "latest" serial number for this Part as a integer.
|
|
||||||
|
|
||||||
If it is not an integer the result is 0
|
|
||||||
"""
|
|
||||||
latest = self.getLatestSerialNumber()
|
|
||||||
|
|
||||||
# No serial number = > 0
|
|
||||||
if latest is None:
|
|
||||||
latest = 0
|
|
||||||
|
|
||||||
# Attempt to turn into an integer and return
|
|
||||||
try:
|
|
||||||
latest = int(latest)
|
|
||||||
return latest
|
|
||||||
except Exception:
|
|
||||||
# not an integer so 0
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def getSerialNumberString(self, quantity=1):
|
|
||||||
"""Return a formatted string representing the next available serial numbers, given a certain quantity of items."""
|
|
||||||
latest = self.getLatestSerialNumber()
|
|
||||||
|
|
||||||
quantity = int(quantity)
|
|
||||||
|
|
||||||
# No serial numbers can be found, assume 1 as the first serial
|
|
||||||
if latest is None:
|
|
||||||
latest = 0
|
|
||||||
|
|
||||||
# Attempt to turn into an integer
|
|
||||||
try:
|
|
||||||
latest = int(latest)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if type(latest) is int:
|
|
||||||
|
|
||||||
if quantity >= 2:
|
|
||||||
text = '{n} - {m}'.format(n=latest + 1, m=latest + 1 + quantity)
|
|
||||||
|
|
||||||
return _('Next available serial numbers are') + ' ' + text
|
|
||||||
else:
|
|
||||||
text = str(latest + 1)
|
|
||||||
|
|
||||||
return _('Next available serial number is') + ' ' + text
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Non-integer values, no option but to return latest
|
|
||||||
|
|
||||||
return _('Most recent serial number is') + ' ' + str(latest)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def full_name(self):
|
def full_name(self):
|
||||||
|
|||||||
@@ -277,7 +277,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if roles.part.change %}
|
{% if roles.part.change %}
|
||||||
<button class='btn btn-success' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
|
<button class='btn btn-success' type='button' title='{% trans "Add BOM Item" %}' id='bom-item-new'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %}
|
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -286,12 +286,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% include "part/bom.html" with part=part %}
|
{% include "part/bom.html" with part=part %}
|
||||||
{% if roles.part.change %}
|
|
||||||
<button class='btn btn-success' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new-footer'>
|
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %}
|
|
||||||
</button>
|
|
||||||
<br/>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -618,19 +612,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("[id^=bom-item-new]").click(function () {
|
$("[id^=bom-item-new]").click(function () {
|
||||||
|
addBomItem({{ part.pk }}, {
|
||||||
var fields = bomItemFields();
|
|
||||||
|
|
||||||
fields.part.value = {{ part.pk }};
|
|
||||||
fields.sub_part.filters = {
|
|
||||||
active: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructForm('{% url "api-bom-list" %}', {
|
|
||||||
fields: fields,
|
|
||||||
method: 'POST',
|
|
||||||
title: '{% trans "Create BOM Item" %}',
|
|
||||||
focus: 'sub_part',
|
|
||||||
onSuccess: function() {
|
onSuccess: function() {
|
||||||
$('#bom-table').bootstrapTable('refresh');
|
$('#bom-table').bootstrapTable('refresh');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,12 +323,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if part.trackable and part.getLatestSerialNumber %}
|
{% with part.get_latest_serial_number as sn %}
|
||||||
|
{% if part.trackable and sn %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-hashtag'></span></td>
|
<td><span class='fas fa-hashtag'></span></td>
|
||||||
<td>{% trans "Latest Serial Number" %}</td>
|
<td>{% trans "Latest Serial Number" %}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ part.getLatestSerialNumber }}
|
{{ sn }}
|
||||||
<div class='btn-group float-right' role='group'>
|
<div class='btn-group float-right' role='group'>
|
||||||
<a class='btn btn-small btn-outline-secondary text-sm' href='#' id='serial-number-search' title='{% trans "Search for serial number" %}'>
|
<a class='btn btn-small btn-outline-secondary text-sm' href='#' id='serial-number-search' title='{% trans "Search for serial number" %}'>
|
||||||
<span class='fas fa-search'></span>
|
<span class='fas fa-search'></span>
|
||||||
@@ -337,6 +338,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
{% if part.default_location %}
|
{% if part.default_location %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-search-location'></span></td>
|
<td><span class='fas fa-search-location'></span></td>
|
||||||
|
|||||||
+36
-11
@@ -16,6 +16,7 @@ from plugin.base.action.api import ActionPluginView
|
|||||||
from plugin.base.barcodes.api import barcode_api_urls
|
from plugin.base.barcodes.api import barcode_api_urls
|
||||||
from plugin.base.locate.api import LocatePluginView
|
from plugin.base.locate.api import LocatePluginView
|
||||||
from plugin.models import PluginConfig, PluginSetting
|
from plugin.models import PluginConfig, PluginSetting
|
||||||
|
from plugin.plugin import InvenTreePlugin
|
||||||
from plugin.registry import registry
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +147,38 @@ class PluginSettingList(ListAPI):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def check_plugin(plugin_slug: str) -> InvenTreePlugin:
|
||||||
|
"""Check that a plugin for the provided slug exsists and get the config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_slug (str): Slug for plugin.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFound: If plugin is not installed
|
||||||
|
NotFound: If plugin is not correctly registered
|
||||||
|
NotFound: If plugin is not active
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InvenTreePlugin: The config object for the provided plugin.
|
||||||
|
"""
|
||||||
|
# Check that the 'plugin' specified is valid!
|
||||||
|
if not PluginConfig.objects.filter(key=plugin_slug).exists():
|
||||||
|
raise NotFound(detail=f"Plugin '{plugin_slug}' not installed")
|
||||||
|
|
||||||
|
# Get the list of settings available for the specified plugin
|
||||||
|
plugin = registry.get_plugin(plugin_slug)
|
||||||
|
|
||||||
|
if plugin is None:
|
||||||
|
# This only occurs if the plugin mechanism broke
|
||||||
|
raise NotFound(detail=f"Plugin '{plugin_slug}' not found") # pragma: no cover
|
||||||
|
|
||||||
|
# Check that the plugin is activated
|
||||||
|
if not plugin.is_active():
|
||||||
|
raise NotFound(detail=f"Plugin '{plugin_slug}' is not active")
|
||||||
|
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
class PluginSettingDetail(RetrieveUpdateAPI):
|
class PluginSettingDetail(RetrieveUpdateAPI):
|
||||||
"""Detail endpoint for a plugin-specific setting.
|
"""Detail endpoint for a plugin-specific setting.
|
||||||
|
|
||||||
@@ -164,18 +197,10 @@ class PluginSettingDetail(RetrieveUpdateAPI):
|
|||||||
plugin_slug = self.kwargs['plugin']
|
plugin_slug = self.kwargs['plugin']
|
||||||
key = self.kwargs['key']
|
key = self.kwargs['key']
|
||||||
|
|
||||||
# Check that the 'plugin' specified is valid!
|
# Look up plugin
|
||||||
if not PluginConfig.objects.filter(key=plugin_slug).exists():
|
plugin = check_plugin(plugin_slug)
|
||||||
raise NotFound(detail=f"Plugin '{plugin_slug}' not installed")
|
|
||||||
|
|
||||||
# Get the list of settings available for the specified plugin
|
settings = getattr(plugin, 'settings', {})
|
||||||
plugin = registry.get_plugin(plugin_slug)
|
|
||||||
|
|
||||||
if plugin is None:
|
|
||||||
# This only occurs if the plugin mechanism broke
|
|
||||||
raise NotFound(detail=f"Plugin '{plugin_slug}' not found") # pragma: no cover
|
|
||||||
|
|
||||||
settings = getattr(plugin, 'SETTINGS', {})
|
|
||||||
|
|
||||||
if key not in settings:
|
if key not in settings:
|
||||||
raise NotFound(detail=f"Plugin '{plugin_slug}' has no setting matching '{key}'")
|
raise NotFound(detail=f"Plugin '{plugin_slug}' has no setting matching '{key}'")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.urls import path, re_path
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ from InvenTree.helpers import hash_barcode
|
|||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.builtin.barcodes.inventree_barcode import (
|
from plugin.builtin.barcodes.inventree_barcode import (
|
||||||
InvenTreeExternalBarcodePlugin, InvenTreeInternalBarcodePlugin)
|
InvenTreeExternalBarcodePlugin, InvenTreeInternalBarcodePlugin)
|
||||||
|
from users.models import RuleSet
|
||||||
|
|
||||||
|
|
||||||
class BarcodeScan(APIView):
|
class BarcodeScan(APIView):
|
||||||
@@ -139,6 +140,17 @@ class BarcodeAssign(APIView):
|
|||||||
try:
|
try:
|
||||||
instance = model.objects.get(pk=data[label])
|
instance = model.objects.get(pk=data[label])
|
||||||
|
|
||||||
|
# Check that the user has the required permission
|
||||||
|
app_label = model._meta.app_label
|
||||||
|
model_name = model._meta.model_name
|
||||||
|
|
||||||
|
table = f"{app_label}_{model_name}"
|
||||||
|
|
||||||
|
if not RuleSet.check_table_permission(request.user, table, "change"):
|
||||||
|
raise PermissionDenied({
|
||||||
|
"error": f"You do not have the required permissions for {table}"
|
||||||
|
})
|
||||||
|
|
||||||
instance.assign_barcode(
|
instance.assign_barcode(
|
||||||
barcode_data=barcode_data,
|
barcode_data=barcode_data,
|
||||||
barcode_hash=barcode_hash,
|
barcode_hash=barcode_hash,
|
||||||
@@ -210,6 +222,17 @@ class BarcodeUnassign(APIView):
|
|||||||
label: _('No match found for provided value')
|
label: _('No match found for provided value')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Check that the user has the required permission
|
||||||
|
app_label = model._meta.app_label
|
||||||
|
model_name = model._meta.model_name
|
||||||
|
|
||||||
|
table = f"{app_label}_{model_name}"
|
||||||
|
|
||||||
|
if not RuleSet.check_table_permission(request.user, table, "change"):
|
||||||
|
raise PermissionDenied({
|
||||||
|
"error": f"You do not have the required permissions for {table}"
|
||||||
|
})
|
||||||
|
|
||||||
# Unassign the barcode data from the model instance
|
# Unassign the barcode data from the model instance
|
||||||
instance.unassign_barcode()
|
instance.unassign_barcode()
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
|||||||
"""Test that a barcode can be associated with a StockItem."""
|
"""Test that a barcode can be associated with a StockItem."""
|
||||||
item = StockItem.objects.get(pk=522)
|
item = StockItem.objects.get(pk=522)
|
||||||
|
|
||||||
|
self.assignRole('stock.change')
|
||||||
|
|
||||||
self.assertEqual(len(item.barcode_hash), 0)
|
self.assertEqual(len(item.barcode_hash), 0)
|
||||||
|
|
||||||
barcode_data = 'A-TEST-BARCODE-STRING'
|
barcode_data = 'A-TEST-BARCODE-STRING'
|
||||||
|
|||||||
@@ -214,6 +214,139 @@ class ScheduleMixin:
|
|||||||
logger.warning("unregister_tasks failed, database not ready")
|
logger.warning("unregister_tasks failed, database not ready")
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationMixin:
|
||||||
|
"""Mixin class that allows custom validation for various parts of InvenTree
|
||||||
|
|
||||||
|
Custom generation and validation functionality can be provided for:
|
||||||
|
|
||||||
|
- Part names
|
||||||
|
- Part IPN (internal part number) values
|
||||||
|
- Serial numbers
|
||||||
|
- Batch codes
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Multiple ValidationMixin plugins can be used simultaneously
|
||||||
|
- The stub methods provided here generally return None (null value).
|
||||||
|
- The "first" plugin to return a non-null value for a particular method "wins"
|
||||||
|
- In the case of "validation" functions, all loaded plugins are checked until an exception is thrown
|
||||||
|
|
||||||
|
Implementing plugins may override any of the following methods which are of interest.
|
||||||
|
|
||||||
|
For 'validation' methods, there are three 'acceptable' outcomes:
|
||||||
|
- The method determines that the value is 'invalid' and raises a django.core.exceptions.ValidationError
|
||||||
|
- The method passes and returns None (the code then moves on to the next plugin)
|
||||||
|
- The method passes and returns True (and no subsequent plugins are checked)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
class MixinMeta:
|
||||||
|
"""Metaclass for this mixin"""
|
||||||
|
MIXIN_NAME = "Validation"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Register the mixin"""
|
||||||
|
super().__init__()
|
||||||
|
self.add_mixin('validation', True, __class__)
|
||||||
|
|
||||||
|
def validate_part_name(self, name: str):
|
||||||
|
"""Perform validation on a proposed Part name
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
name: The proposed part name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None or True
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError if the proposed name is objectionable
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_part_ipn(self, ipn: str):
|
||||||
|
"""Perform validation on a proposed Part IPN (internal part number)
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
ipn: The proposed part IPN
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None or True
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError if the proposed IPN is objectionable
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_batch_code(self, batch_code: str):
|
||||||
|
"""Validate the supplied batch code
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
batch_code: The proposed batch code (string)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None or True
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError if the proposed batch code is objectionable
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_batch_code(self):
|
||||||
|
"""Generate a new batch code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A new batch code (string) or None
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_serial_number(self, serial: str):
|
||||||
|
"""Validate the supplied serial number
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
serial: The proposed serial number (string)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None or True
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError if the proposed serial is objectionable
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert_serial_to_int(self, serial: str):
|
||||||
|
"""Convert a serial number (string) into an integer representation.
|
||||||
|
|
||||||
|
This integer value is used for efficient sorting based on serial numbers.
|
||||||
|
|
||||||
|
A plugin which implements this method can either return:
|
||||||
|
|
||||||
|
- An integer based on the serial string, according to some algorithm
|
||||||
|
- A fixed value, such that serial number sorting reverts to the string representation
|
||||||
|
- None (null value) to let any other plugins perform the converrsion
|
||||||
|
|
||||||
|
Note that there is no requirement for the returned integer value to be unique.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
serial: Serial value (string)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
integer representation of the serial number, or None
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def increment_serial_number(self, serial: str):
|
||||||
|
"""Return the next sequential serial based on the provided value.
|
||||||
|
|
||||||
|
A plugin which implements this method can either return:
|
||||||
|
|
||||||
|
- A string which represents the "next" serial number in the sequence
|
||||||
|
- None (null value) if the next value could not be determined
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
serial: Current serial value (string)
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class UrlsMixin:
|
class UrlsMixin:
|
||||||
"""Mixin that enables custom URLs for the plugin."""
|
"""Mixin that enables custom URLs for the plugin."""
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertIn('Missing data:', str(response.data))
|
self.assertIn('Missing data:', str(response.data))
|
||||||
|
|
||||||
|
# Permission error check
|
||||||
|
response = self.assign(
|
||||||
|
{
|
||||||
|
'barcode': 'abcdefg',
|
||||||
|
'part': 1,
|
||||||
|
'stockitem': 1,
|
||||||
|
},
|
||||||
|
expected_code=403
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assignRole('part.change')
|
||||||
|
self.assignRole('stock.change')
|
||||||
|
|
||||||
# Provide too many fields
|
# Provide too many fields
|
||||||
response = self.assign(
|
response = self.assign(
|
||||||
{
|
{
|
||||||
@@ -188,6 +201,8 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
barcode = 'xyz-123'
|
barcode = 'xyz-123'
|
||||||
|
|
||||||
|
self.assignRole('part.change')
|
||||||
|
|
||||||
# Test that an initial scan yields no results
|
# Test that an initial scan yields no results
|
||||||
response = self.scan(
|
response = self.scan(
|
||||||
{
|
{
|
||||||
@@ -196,6 +211,8 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
|||||||
expected_code=400
|
expected_code=400
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.assignRole('part.change')
|
||||||
|
|
||||||
# Attempt to assign to an invalid part ID
|
# Attempt to assign to an invalid part ID
|
||||||
response = self.assign(
|
response = self.assign(
|
||||||
{
|
{
|
||||||
@@ -247,6 +264,8 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertIn('Barcode matches existing item', str(response.data['error']))
|
self.assertIn('Barcode matches existing item', str(response.data['error']))
|
||||||
|
|
||||||
|
self.assignRole('part.change')
|
||||||
|
|
||||||
# Now test that we can unassign the barcode data also
|
# Now test that we can unassign the barcode data also
|
||||||
response = self.unassign(
|
response = self.unassign(
|
||||||
{
|
{
|
||||||
@@ -265,6 +284,17 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
barcode = '555555555555555555555555'
|
barcode = '555555555555555555555555'
|
||||||
|
|
||||||
|
# Assign random barcode data to a StockLocation instance
|
||||||
|
response = self.assign(
|
||||||
|
data={
|
||||||
|
'barcode': barcode,
|
||||||
|
'stocklocation': 1,
|
||||||
|
},
|
||||||
|
expected_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assignRole('stock_location.change')
|
||||||
|
|
||||||
# Assign random barcode data to a StockLocation instance
|
# Assign random barcode data to a StockLocation instance
|
||||||
response = self.assign(
|
response = self.assign(
|
||||||
data={
|
data={
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from ..base.barcodes.mixins import BarcodeMixin
|
|||||||
from ..base.event.mixins import EventMixin
|
from ..base.event.mixins import EventMixin
|
||||||
from ..base.integration.mixins import (APICallMixin, AppMixin, NavigationMixin,
|
from ..base.integration.mixins import (APICallMixin, AppMixin, NavigationMixin,
|
||||||
PanelMixin, ScheduleMixin,
|
PanelMixin, ScheduleMixin,
|
||||||
SettingsMixin, UrlsMixin)
|
SettingsMixin, UrlsMixin,
|
||||||
|
ValidationMixin)
|
||||||
from ..base.label.mixins import LabelPrintingMixin
|
from ..base.label.mixins import LabelPrintingMixin
|
||||||
from ..base.locate.mixins import LocateMixin
|
from ..base.locate.mixins import LocateMixin
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ __all__ = [
|
|||||||
'ActionMixin',
|
'ActionMixin',
|
||||||
'BarcodeMixin',
|
'BarcodeMixin',
|
||||||
'LocateMixin',
|
'LocateMixin',
|
||||||
|
'ValidationMixin',
|
||||||
'SingleNotificationMethod',
|
'SingleNotificationMethod',
|
||||||
'BulkNotificationMethod',
|
'BulkNotificationMethod',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from django.contrib import admin
|
|||||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||||
from django.urls import clear_url_caches, include, re_path
|
from django.urls import clear_url_caches, include, re_path
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from maintenance_mode.core import (get_maintenance_mode, maintenance_mode_on,
|
from maintenance_mode.core import (get_maintenance_mode, maintenance_mode_on,
|
||||||
set_maintenance_mode)
|
set_maintenance_mode)
|
||||||
@@ -67,6 +68,21 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
return self.plugins[slug]
|
return self.plugins[slug]
|
||||||
|
|
||||||
|
def set_plugin_state(self, slug, state):
|
||||||
|
"""Set the state(active/inactive) of a plugin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug (str): Plugin slug
|
||||||
|
state (bool): Plugin state - true = active, false = inactive
|
||||||
|
"""
|
||||||
|
if slug not in self.plugins_full:
|
||||||
|
logger.warning(f"Plugin registry has no record of plugin '{slug}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
plugin = self.plugins_full[slug].db
|
||||||
|
plugin.active = state
|
||||||
|
plugin.save()
|
||||||
|
|
||||||
def call_plugin_function(self, slug, func, *args, **kwargs):
|
def call_plugin_function(self, slug, func, *args, **kwargs):
|
||||||
"""Call a member function (named by 'func') of the plugin named by 'slug'.
|
"""Call a member function (named by 'func') of the plugin named by 'slug'.
|
||||||
|
|
||||||
@@ -349,6 +365,8 @@ class PluginsRegistry:
|
|||||||
Raises:
|
Raises:
|
||||||
error: IntegrationPluginError
|
error: IntegrationPluginError
|
||||||
"""
|
"""
|
||||||
|
# Imports need to be in this level to prevent early db model imports
|
||||||
|
from InvenTree import version
|
||||||
from plugin.models import PluginConfig
|
from plugin.models import PluginConfig
|
||||||
|
|
||||||
def safe_reference(plugin, key: str, active: bool = True):
|
def safe_reference(plugin, key: str, active: bool = True):
|
||||||
@@ -372,7 +390,7 @@ class PluginsRegistry:
|
|||||||
plg_key = slugify(plg.SLUG if getattr(plg, 'SLUG', None) else plg_name) # keys are slugs!
|
plg_key = slugify(plg.SLUG if getattr(plg, 'SLUG', None) else plg_name) # keys are slugs!
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plg_db, _ = PluginConfig.objects.get_or_create(key=plg_key, name=plg_name)
|
plg_db, _created = PluginConfig.objects.get_or_create(key=plg_key, name=plg_name)
|
||||||
except (OperationalError, ProgrammingError) as error:
|
except (OperationalError, ProgrammingError) as error:
|
||||||
# Exception if the database has not been migrated yet - check if test are running - raise if not
|
# Exception if the database has not been migrated yet - check if test are running - raise if not
|
||||||
if not settings.PLUGIN_TESTING:
|
if not settings.PLUGIN_TESTING:
|
||||||
@@ -380,6 +398,7 @@ class PluginsRegistry:
|
|||||||
plg_db = None
|
plg_db = None
|
||||||
except (IntegrityError) as error: # pragma: no cover
|
except (IntegrityError) as error: # pragma: no cover
|
||||||
logger.error(f"Error initializing plugin `{plg_name}`: {error}")
|
logger.error(f"Error initializing plugin `{plg_name}`: {error}")
|
||||||
|
handle_error(error, log_name='init')
|
||||||
|
|
||||||
# Append reference to plugin
|
# Append reference to plugin
|
||||||
plg.db = plg_db
|
plg.db = plg_db
|
||||||
@@ -406,7 +425,16 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
# Run version check for plugin
|
# Run version check for plugin
|
||||||
if (plg_i.MIN_VERSION or plg_i.MAX_VERSION) and not plg_i.check_version():
|
if (plg_i.MIN_VERSION or plg_i.MAX_VERSION) and not plg_i.check_version():
|
||||||
|
# Disable plugin
|
||||||
safe_reference(plugin=plg_i, key=plg_key, active=False)
|
safe_reference(plugin=plg_i, key=plg_key, active=False)
|
||||||
|
|
||||||
|
_msg = _(f'Plugin `{plg_name}` is not compatible with the current InvenTree version {version.inventreeVersion()}!')
|
||||||
|
if plg_i.MIN_VERSION:
|
||||||
|
_msg += _(f'Plugin requires at least version {plg_i.MIN_VERSION}')
|
||||||
|
if plg_i.MAX_VERSION:
|
||||||
|
_msg += _(f'Plugin requires at most version {plg_i.MAX_VERSION}')
|
||||||
|
# Log to error stack
|
||||||
|
log_error(_msg, reference='init')
|
||||||
else:
|
else:
|
||||||
safe_reference(plugin=plg_i, key=plg_key)
|
safe_reference(plugin=plg_i, key=plg_key)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
@@ -467,7 +495,7 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'):
|
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'):
|
||||||
|
|
||||||
for _, plugin in plugins:
|
for _key, plugin in plugins:
|
||||||
|
|
||||||
if plugin.mixin_enabled('schedule'):
|
if plugin.mixin_enabled('schedule'):
|
||||||
config = plugin.plugin_config()
|
config = plugin.plugin_config()
|
||||||
@@ -522,7 +550,7 @@ class PluginsRegistry:
|
|||||||
apps_changed = False
|
apps_changed = False
|
||||||
|
|
||||||
# add them to the INSTALLED_APPS
|
# add them to the INSTALLED_APPS
|
||||||
for _, plugin in plugins:
|
for _key, plugin in plugins:
|
||||||
if plugin.mixin_enabled('app'):
|
if plugin.mixin_enabled('app'):
|
||||||
plugin_path = self._get_plugin_path(plugin)
|
plugin_path = self._get_plugin_path(plugin)
|
||||||
if plugin_path not in settings.INSTALLED_APPS:
|
if plugin_path not in settings.INSTALLED_APPS:
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Sample plugin which demonstrates custom validation functionality"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from plugin import InvenTreePlugin
|
||||||
|
from plugin.mixins import SettingsMixin, ValidationMixin
|
||||||
|
|
||||||
|
|
||||||
|
class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
|
||||||
|
"""A sample plugin class for demonstrating custom validation functions"""
|
||||||
|
|
||||||
|
NAME = "CustomValidator"
|
||||||
|
SLUG = "validator"
|
||||||
|
TITLE = "Custom Validator Plugin"
|
||||||
|
DESCRIPTION = "A sample plugin for demonstrating custom validation functionality"
|
||||||
|
VERSION = "0.1"
|
||||||
|
|
||||||
|
SETTINGS = {
|
||||||
|
'ILLEGAL_PART_CHARS': {
|
||||||
|
'name': 'Illegal Part Characters',
|
||||||
|
'description': 'Characters which are not allowed to appear in Part names',
|
||||||
|
'default': '!@#$%^&*()~`'
|
||||||
|
},
|
||||||
|
'IPN_MUST_CONTAIN_Q': {
|
||||||
|
'name': 'IPN Q Requirement',
|
||||||
|
'description': 'Part IPN field must contain the character Q',
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
'SERIAL_MUST_BE_PALINDROME': {
|
||||||
|
'name': 'Palindromic Serials',
|
||||||
|
'description': 'Serial numbers must be palindromic',
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
'BATCH_CODE_PREFIX': {
|
||||||
|
'name': 'Batch prefix',
|
||||||
|
'description': 'Required prefix for batch code',
|
||||||
|
'default': '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_part_name(self, name: str):
|
||||||
|
"""Validate part name"""
|
||||||
|
|
||||||
|
illegal_chars = self.get_setting('ILLEGAL_PART_CHARS')
|
||||||
|
|
||||||
|
for c in illegal_chars:
|
||||||
|
if c in name:
|
||||||
|
raise ValidationError(f"Illegal character in part name: '{c}'")
|
||||||
|
|
||||||
|
def validate_part_ipn(self, ipn: str):
|
||||||
|
"""Validate part IPN"""
|
||||||
|
|
||||||
|
if self.get_setting('IPN_MUST_CONTAIN_Q') and 'Q' not in ipn:
|
||||||
|
raise ValidationError("IPN must contain 'Q'")
|
||||||
|
|
||||||
|
def validate_serial_number(self, serial: str):
|
||||||
|
"""Validate serial number for a given StockItem"""
|
||||||
|
|
||||||
|
if self.get_setting('SERIAL_MUST_BE_PALINDROME'):
|
||||||
|
if serial != serial[::-1]:
|
||||||
|
raise ValidationError("Serial must be a palindrome")
|
||||||
|
|
||||||
|
def validate_batch_code(self, batch_code: str):
|
||||||
|
"""Ensure that a particular batch code meets specification"""
|
||||||
|
|
||||||
|
prefix = self.get_setting('BATCH_CODE_PREFIX')
|
||||||
|
|
||||||
|
if not batch_code.startswith(prefix):
|
||||||
|
raise ValidationError(f"Batch code must start with '{prefix}'")
|
||||||
|
|
||||||
|
def generate_batch_code(self):
|
||||||
|
"""Generate a new batch code."""
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
return f"BATCH-{now.year}:{now.month}:{now.day}"
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase, PluginMixin
|
||||||
|
|
||||||
|
|
||||||
class PluginDetailAPITest(InvenTreeAPITestCase):
|
class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||||
"""Tests the plugin API endpoints."""
|
"""Tests the plugin API endpoints."""
|
||||||
|
|
||||||
roles = [
|
roles = [
|
||||||
@@ -72,26 +72,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
def test_admin_action(self):
|
def test_admin_action(self):
|
||||||
"""Test the PluginConfig action commands."""
|
"""Test the PluginConfig action commands."""
|
||||||
from plugin import registry
|
|
||||||
from plugin.models import PluginConfig
|
|
||||||
|
|
||||||
url = reverse('admin:plugin_pluginconfig_changelist')
|
url = reverse('admin:plugin_pluginconfig_changelist')
|
||||||
fixtures = PluginConfig.objects.all()
|
|
||||||
|
|
||||||
# check if plugins were registered -> in some test setups the startup has no db access
|
test_plg = self.plugin_confs.first()
|
||||||
print(f'[PLUGIN-TEST] currently {len(fixtures)} plugin entries found')
|
|
||||||
if not fixtures:
|
|
||||||
registry.reload_plugins()
|
|
||||||
fixtures = PluginConfig.objects.all()
|
|
||||||
print(f'Reloaded plugins - now {len(fixtures)} entries found')
|
|
||||||
|
|
||||||
print([str(a) for a in fixtures])
|
|
||||||
fixtures = fixtures[0:1]
|
|
||||||
# deactivate plugin
|
# deactivate plugin
|
||||||
response = self.client.post(url, {
|
response = self.client.post(url, {
|
||||||
'action': 'plugin_deactivate',
|
'action': 'plugin_deactivate',
|
||||||
'index': 0,
|
'index': 0,
|
||||||
'_selected_action': [f.pk for f in fixtures],
|
'_selected_action': [test_plg.pk],
|
||||||
}, follow=True)
|
}, follow=True)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@@ -99,7 +87,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
|||||||
response = self.client.post(url, {
|
response = self.client.post(url, {
|
||||||
'action': 'plugin_deactivate',
|
'action': 'plugin_deactivate',
|
||||||
'index': 0,
|
'index': 0,
|
||||||
'_selected_action': [f.pk for f in fixtures],
|
'_selected_action': [test_plg.pk],
|
||||||
}, follow=True)
|
}, follow=True)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@@ -107,47 +95,27 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
|||||||
response = self.client.post(url, {
|
response = self.client.post(url, {
|
||||||
'action': 'plugin_activate',
|
'action': 'plugin_activate',
|
||||||
'index': 0,
|
'index': 0,
|
||||||
'_selected_action': [f.pk for f in fixtures],
|
'_selected_action': [test_plg.pk],
|
||||||
}, follow=True)
|
}, follow=True)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# activate everything
|
|
||||||
fixtures = PluginConfig.objects.all()
|
|
||||||
response = self.client.post(url, {
|
|
||||||
'action': 'plugin_activate',
|
|
||||||
'index': 0,
|
|
||||||
'_selected_action': [f.pk for f in fixtures],
|
|
||||||
}, follow=True)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
fixtures = PluginConfig.objects.filter(active=True)
|
|
||||||
# save to deactivate a plugin
|
# save to deactivate a plugin
|
||||||
response = self.client.post(reverse('admin:plugin_pluginconfig_change', args=(fixtures.first().pk, )), {
|
response = self.client.post(reverse('admin:plugin_pluginconfig_change', args=(test_plg.pk, )), {
|
||||||
'_save': 'Save',
|
'_save': 'Save',
|
||||||
}, follow=True)
|
}, follow=True)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_model(self):
|
def test_model(self):
|
||||||
"""Test the PluginConfig model."""
|
"""Test the PluginConfig model."""
|
||||||
from plugin import registry
|
|
||||||
from plugin.models import PluginConfig
|
|
||||||
|
|
||||||
fixtures = PluginConfig.objects.all()
|
|
||||||
|
|
||||||
# check if plugins were registered
|
|
||||||
if not fixtures:
|
|
||||||
registry.reload_plugins()
|
|
||||||
fixtures = PluginConfig.objects.all()
|
|
||||||
|
|
||||||
# check mixin registry
|
# check mixin registry
|
||||||
plg = fixtures.first()
|
plg = self.plugin_confs.first()
|
||||||
mixin_dict = plg.mixins()
|
mixin_dict = plg.mixins()
|
||||||
self.assertIn('base', mixin_dict)
|
self.assertIn('base', mixin_dict)
|
||||||
self.assertDictContainsSubset({'base': {'key': 'base', 'human_name': 'base'}}, mixin_dict)
|
self.assertDictContainsSubset({'base': {'key': 'base', 'human_name': 'base'}}, mixin_dict)
|
||||||
|
|
||||||
# check reload on save
|
# check reload on save
|
||||||
with self.assertWarns(Warning) as cm:
|
with self.assertWarns(Warning) as cm:
|
||||||
plg_inactive = fixtures.filter(active=False).first()
|
plg_inactive = self.plugin_confs.filter(active=False).first()
|
||||||
plg_inactive.active = True
|
plg_inactive.active = True
|
||||||
plg_inactive.save()
|
plg_inactive.save()
|
||||||
self.assertEqual(cm.warning.args[0], 'A reload was triggered')
|
self.assertEqual(cm.warning.args[0], 'A reload was triggered')
|
||||||
|
|||||||
+21
-9
@@ -563,23 +563,35 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
|||||||
|
|
||||||
# If serial numbers are specified, check that they match!
|
# If serial numbers are specified, check that they match!
|
||||||
try:
|
try:
|
||||||
serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt())
|
serials = extract_serial_numbers(
|
||||||
|
serial_numbers,
|
||||||
|
quantity,
|
||||||
|
part.get_latest_serial_number()
|
||||||
|
)
|
||||||
|
|
||||||
# Determine if any of the specified serial numbers already exist!
|
# Determine if any of the specified serial numbers are invalid
|
||||||
existing = []
|
# Note "invalid" means either they already exist, or do not pass custom rules
|
||||||
|
invalid = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
for serial in serials:
|
for serial in serials:
|
||||||
if part.checkIfSerialNumberExists(serial):
|
try:
|
||||||
existing.append(serial)
|
part.validate_serial_number(serial, raise_error=True)
|
||||||
|
except DjangoValidationError as exc:
|
||||||
|
# Catch raised error to extract specific error information
|
||||||
|
invalid.append(serial)
|
||||||
|
|
||||||
if len(existing) > 0:
|
if exc.message not in errors:
|
||||||
|
errors.append(exc.message)
|
||||||
|
|
||||||
msg = _("The following serial numbers already exist")
|
if len(errors) > 0:
|
||||||
|
|
||||||
|
msg = _("The following serial numbers already exist or are invalid")
|
||||||
msg += " : "
|
msg += " : "
|
||||||
msg += ",".join([str(e) for e in existing])
|
msg += ",".join([str(e) for e in invalid])
|
||||||
|
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'serial_numbers': [msg],
|
'serial_numbers': errors + [msg]
|
||||||
})
|
})
|
||||||
|
|
||||||
except DjangoValidationError as e:
|
except DjangoValidationError as e:
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 1000
|
serial: 1000
|
||||||
|
serial_int: 1000
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -121,6 +122,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 1
|
serial: 1
|
||||||
|
serial_int: 1
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -133,6 +135,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 2
|
serial: 2
|
||||||
|
serial_int: 2
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -145,6 +148,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 3
|
serial: 3
|
||||||
|
serial_int: 3
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -157,6 +161,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 4
|
serial: 4
|
||||||
|
serial_int: 4
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -169,6 +174,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 5
|
serial: 5
|
||||||
|
serial_int: 5
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -181,6 +187,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 10
|
serial: 10
|
||||||
|
serial_int: 10
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -193,6 +200,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 11
|
serial: 11
|
||||||
|
serial_int: 11
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -205,6 +213,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 12
|
serial: 12
|
||||||
|
serial_int: 12
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -217,6 +226,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 20
|
serial: 20
|
||||||
|
serial_int: 20
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -231,6 +241,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 21
|
serial: 21
|
||||||
|
serial_int: 21
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@@ -245,6 +256,7 @@
|
|||||||
location: 7
|
location: 7
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 22
|
serial: 22
|
||||||
|
serial_int: 22
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
|
|||||||
+70
-14
@@ -180,9 +180,24 @@ class StockItemManager(TreeManager):
|
|||||||
def generate_batch_code():
|
def generate_batch_code():
|
||||||
"""Generate a default 'batch code' for a new StockItem.
|
"""Generate a default 'batch code' for a new StockItem.
|
||||||
|
|
||||||
This uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
|
By default, this uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
|
||||||
which can be passed through a simple template.
|
which can be passed through a simple template.
|
||||||
|
|
||||||
|
Also, this function is exposed to the ValidationMixin plugin class,
|
||||||
|
allowing custom plugins to be used to generate new batch code values
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# First, check if any plugins can generate batch codes
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
for plugin in registry.with_mixin('validation'):
|
||||||
|
batch = plugin.generate_batch_code()
|
||||||
|
|
||||||
|
if batch is not None:
|
||||||
|
# Return the first non-null value generated by a plugin
|
||||||
|
return batch
|
||||||
|
|
||||||
|
# If we get to this point, no plugin was able to generate a new batch code
|
||||||
batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '')
|
batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '')
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -260,15 +275,38 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
This is used for efficient numerical sorting
|
This is used for efficient numerical sorting
|
||||||
"""
|
"""
|
||||||
serial = getattr(self, 'serial', '')
|
|
||||||
|
serial = str(getattr(self, 'serial', '')).strip()
|
||||||
|
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
# First, let any plugins convert this serial number to an integer value
|
||||||
|
# If a non-null value is returned (by any plugin) we will use that
|
||||||
|
|
||||||
|
serial_int = None
|
||||||
|
|
||||||
|
for plugin in registry.with_mixin('validation'):
|
||||||
|
serial_int = plugin.convert_serial_to_int(serial)
|
||||||
|
|
||||||
|
if serial_int is not None:
|
||||||
|
# Save the first returned result
|
||||||
|
# Ensure that it is clipped within a range allowed in the database schema
|
||||||
|
clip = 0x7fffffff
|
||||||
|
|
||||||
|
serial_int = abs(serial_int)
|
||||||
|
|
||||||
|
if serial_int > clip:
|
||||||
|
serial_int = clip
|
||||||
|
|
||||||
|
self.serial_int = serial_int
|
||||||
|
return
|
||||||
|
|
||||||
|
# If we get to this point, none of the available plugins provided an integer value
|
||||||
|
|
||||||
# Default value if we cannot convert to an integer
|
# Default value if we cannot convert to an integer
|
||||||
serial_int = 0
|
serial_int = 0
|
||||||
|
|
||||||
if serial is not None:
|
if serial not in [None, '']:
|
||||||
|
|
||||||
serial = str(serial).strip()
|
|
||||||
|
|
||||||
serial_int = extract_int(serial)
|
serial_int = extract_int(serial)
|
||||||
|
|
||||||
self.serial_int = serial_int
|
self.serial_int = serial_int
|
||||||
@@ -408,16 +446,32 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
# If the serial number is set, make sure it is not a duplicate
|
# If the serial number is set, make sure it is not a duplicate
|
||||||
if self.serial:
|
if self.serial:
|
||||||
# Query to look for duplicate serial numbers
|
|
||||||
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
|
|
||||||
stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
|
|
||||||
|
|
||||||
# Exclude myself from the search
|
self.serial = str(self.serial).strip()
|
||||||
if self.pk is not None:
|
|
||||||
stock = stock.exclude(pk=self.pk)
|
|
||||||
|
|
||||||
if stock.exists():
|
try:
|
||||||
raise ValidationError({"serial": _("StockItem with this serial number already exists")})
|
self.part.validate_serial_number(self.serial, self, raise_error=True)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ValidationError({
|
||||||
|
'serial': exc.message,
|
||||||
|
})
|
||||||
|
|
||||||
|
def validate_batch_code(self):
|
||||||
|
"""Ensure that the batch code is valid for this StockItem.
|
||||||
|
|
||||||
|
- Validation is performed by custom plugins.
|
||||||
|
- By default, no validation checks are performed
|
||||||
|
"""
|
||||||
|
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
for plugin in registry.with_mixin('validation'):
|
||||||
|
try:
|
||||||
|
plugin.validate_batch_code(self.batch)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ValidationError({
|
||||||
|
'batch': exc.message
|
||||||
|
})
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Validate the StockItem object (separate to field validation).
|
"""Validate the StockItem object (separate to field validation).
|
||||||
@@ -438,6 +492,8 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
if type(self.batch) is str:
|
if type(self.batch) is str:
|
||||||
self.batch = self.batch.strip()
|
self.batch = self.batch.strip()
|
||||||
|
|
||||||
|
self.validate_batch_code()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Trackable parts must have integer values for quantity field!
|
# Trackable parts must have integer values for quantity field!
|
||||||
if self.part.trackable:
|
if self.part.trackable:
|
||||||
|
|||||||
@@ -342,7 +342,11 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
|||||||
serial_numbers = data['serial_numbers']
|
serial_numbers = data['serial_numbers']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity, item.part.getLatestSerialNumberInt())
|
serials = InvenTree.helpers.extract_serial_numbers(
|
||||||
|
serial_numbers,
|
||||||
|
quantity,
|
||||||
|
item.part.get_latest_serial_number()
|
||||||
|
)
|
||||||
except DjangoValidationError as e:
|
except DjangoValidationError as e:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'serial_numbers': e.messages,
|
'serial_numbers': e.messages,
|
||||||
@@ -371,7 +375,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
|||||||
serials = InvenTree.helpers.extract_serial_numbers(
|
serials = InvenTree.helpers.extract_serial_numbers(
|
||||||
data['serial_numbers'],
|
data['serial_numbers'],
|
||||||
data['quantity'],
|
data['quantity'],
|
||||||
item.part.getLatestSerialNumberInt()
|
item.part.get_latest_serial_number()
|
||||||
)
|
)
|
||||||
|
|
||||||
item.serializeStock(
|
item.serializeStock(
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
|
|
||||||
# Check that each serial number was created
|
# Check that each serial number was created
|
||||||
for i in range(1, 11):
|
for i in range(1, 11):
|
||||||
self.assertTrue(i in sn)
|
self.assertTrue(str(i) in sn)
|
||||||
|
|
||||||
# Check the unique stock item has been created
|
# Check the unique stock item has been created
|
||||||
|
|
||||||
|
|||||||
+56
-20
@@ -4,8 +4,10 @@ import datetime
|
|||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
from InvenTree.helpers import InvenTreeTestCase
|
from InvenTree.helpers import InvenTreeTestCase
|
||||||
from InvenTree.status_codes import StockHistoryCode
|
from InvenTree.status_codes import StockHistoryCode
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
@@ -140,7 +142,7 @@ class StockTest(StockTestBase):
|
|||||||
item.save()
|
item.save()
|
||||||
item.full_clean()
|
item.full_clean()
|
||||||
|
|
||||||
# Check that valid URLs pass
|
# Check that valid URLs pass - and check custon schemes
|
||||||
for good_url in [
|
for good_url in [
|
||||||
'https://test.com',
|
'https://test.com',
|
||||||
'https://digikey.com/datasheets?file=1010101010101.bin',
|
'https://digikey.com/datasheets?file=1010101010101.bin',
|
||||||
@@ -163,6 +165,47 @@ class StockTest(StockTestBase):
|
|||||||
item.link = long_url
|
item.link = long_url
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
|
@override_settings(EXTRA_URL_SCHEMES=['ssh'])
|
||||||
|
def test_exteneded_schema(self):
|
||||||
|
"""Test that extended URL schemes are allowed"""
|
||||||
|
item = StockItem.objects.get(pk=1)
|
||||||
|
item.link = 'ssh://user:pwd@deb.org:223'
|
||||||
|
item.save()
|
||||||
|
item.full_clean()
|
||||||
|
|
||||||
|
def test_serial_numbers(self):
|
||||||
|
"""Test serial number uniqueness"""
|
||||||
|
|
||||||
|
# Ensure that 'global uniqueness' setting is enabled
|
||||||
|
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', True, self.user)
|
||||||
|
|
||||||
|
part_a = Part.objects.create(name='A', description='A', trackable=True)
|
||||||
|
part_b = Part.objects.create(name='B', description='B', trackable=True)
|
||||||
|
|
||||||
|
# Create a StockItem for part_a
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=part_a,
|
||||||
|
quantity=1,
|
||||||
|
serial='ABCDE',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a StockItem for part_a (but, will error due to identical serial)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=part_b,
|
||||||
|
quantity=1,
|
||||||
|
serial='ABCDE',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now, allow serial numbers to be duplicated between different parts
|
||||||
|
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, self.user)
|
||||||
|
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=part_b,
|
||||||
|
quantity=1,
|
||||||
|
serial='ABCDE',
|
||||||
|
)
|
||||||
|
|
||||||
def test_expiry(self):
|
def test_expiry(self):
|
||||||
"""Test expiry date functionality for StockItem model."""
|
"""Test expiry date functionality for StockItem model."""
|
||||||
today = datetime.datetime.now().date()
|
today = datetime.datetime.now().date()
|
||||||
@@ -848,22 +891,21 @@ class VariantTest(StockTestBase):
|
|||||||
|
|
||||||
def test_serial_numbers(self):
|
def test_serial_numbers(self):
|
||||||
"""Test serial number functionality for variant / template parts."""
|
"""Test serial number functionality for variant / template parts."""
|
||||||
|
|
||||||
|
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, self.user)
|
||||||
|
|
||||||
chair = Part.objects.get(pk=10000)
|
chair = Part.objects.get(pk=10000)
|
||||||
|
|
||||||
# Operations on the top-level object
|
# Operations on the top-level object
|
||||||
self.assertTrue(chair.checkIfSerialNumberExists(1))
|
[self.assertFalse(chair.validate_serial_number(i)) for i in [1, 2, 3, 4, 5, 20, 21, 22]]
|
||||||
self.assertTrue(chair.checkIfSerialNumberExists(2))
|
|
||||||
self.assertTrue(chair.checkIfSerialNumberExists(3))
|
|
||||||
self.assertTrue(chair.checkIfSerialNumberExists(4))
|
|
||||||
self.assertTrue(chair.checkIfSerialNumberExists(5))
|
|
||||||
|
|
||||||
self.assertTrue(chair.checkIfSerialNumberExists(20))
|
self.assertFalse(chair.validate_serial_number(20))
|
||||||
self.assertTrue(chair.checkIfSerialNumberExists(21))
|
self.assertFalse(chair.validate_serial_number(21))
|
||||||
self.assertTrue(chair.checkIfSerialNumberExists(22))
|
self.assertFalse(chair.validate_serial_number(22))
|
||||||
|
|
||||||
self.assertFalse(chair.checkIfSerialNumberExists(30))
|
self.assertTrue(chair.validate_serial_number(30))
|
||||||
|
|
||||||
self.assertEqual(chair.getLatestSerialNumber(), '22')
|
self.assertEqual(chair.get_latest_serial_number(), '22')
|
||||||
|
|
||||||
# Check for conflicting serial numbers
|
# Check for conflicting serial numbers
|
||||||
to_check = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
to_check = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
@@ -874,10 +916,10 @@ class VariantTest(StockTestBase):
|
|||||||
|
|
||||||
# Same operations on a sub-item
|
# Same operations on a sub-item
|
||||||
variant = Part.objects.get(pk=10003)
|
variant = Part.objects.get(pk=10003)
|
||||||
self.assertEqual(variant.getLatestSerialNumber(), '22')
|
self.assertEqual(variant.get_latest_serial_number(), '22')
|
||||||
|
|
||||||
# Create a new serial number
|
# Create a new serial number
|
||||||
n = variant.getLatestSerialNumber()
|
n = variant.get_latest_serial_number()
|
||||||
|
|
||||||
item = StockItem(
|
item = StockItem(
|
||||||
part=variant,
|
part=variant,
|
||||||
@@ -889,12 +931,6 @@ class VariantTest(StockTestBase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
# Verify items with a non-numeric serial don't offer a next serial.
|
|
||||||
item.serial = "string"
|
|
||||||
item.save()
|
|
||||||
|
|
||||||
self.assertEqual(variant.getLatestSerialNumber(), "string")
|
|
||||||
|
|
||||||
# This should pass, although not strictly an int field now.
|
# This should pass, although not strictly an int field now.
|
||||||
item.serial = int(n) + 1
|
item.serial = int(n) + 1
|
||||||
item.save()
|
item.save()
|
||||||
@@ -906,7 +942,7 @@ class VariantTest(StockTestBase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
item.serial += 1
|
item.serial = int(n) + 2
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE" icon="fa-server" %}
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE" icon="fa-server" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_REQUIRE_CONFIRM" icon="fa-check" %}
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_REQUIRE_CONFIRM" icon="fa-check" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_TREE_DEPTH" icon="fa-sitemap" %}
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_TREE_DEPTH" icon="fa-sitemap" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_BACKUP_ENABLE" icon="fa-hdd" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="SERIAL_NUMBER_GLOBALLY_UNIQUE" icon="fa-hashtag" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
|
addBomItem,
|
||||||
constructBomUploadTable,
|
constructBomUploadTable,
|
||||||
deleteBomItems,
|
deleteBomItems,
|
||||||
downloadBomTemplate,
|
downloadBomTemplate,
|
||||||
@@ -28,6 +29,30 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Launch a dialog to add a new BOM line item to a Bill of Materials
|
||||||
|
*/
|
||||||
|
function addBomItem(part_id, options={}) {
|
||||||
|
|
||||||
|
var fields = bomItemFields();
|
||||||
|
|
||||||
|
fields.part.value = part_id;
|
||||||
|
fields.sub_part.filters = {
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructForm('{% url "api-bom-list" %}', {
|
||||||
|
fields: fields,
|
||||||
|
method: 'POST',
|
||||||
|
title: '{% trans "Create BOM Item" %}',
|
||||||
|
focus: 'sub_part',
|
||||||
|
onSuccess: function(response) {
|
||||||
|
handleFormSuccess(response, options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Construct a table of data extracted from a BOM file.
|
/* Construct a table of data extracted from a BOM file.
|
||||||
* This data is used to import a BOM interactively.
|
* This data is used to import a BOM interactively.
|
||||||
*/
|
*/
|
||||||
@@ -1171,6 +1196,13 @@ function loadBomTable(table, options={}) {
|
|||||||
`/part/${row.part}/bom/`
|
`/part/${row.part}/bom/`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
footerFormatter: function(data) {
|
||||||
|
return `
|
||||||
|
<button class='btn btn-success float-right' type='button' title='{% trans "Add BOM Item" %}' id='bom-item-new-footer'>
|
||||||
|
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1297,6 +1329,15 @@ function loadBomTable(table, options={}) {
|
|||||||
// In editing mode, attached editables to the appropriate table elements
|
// In editing mode, attached editables to the appropriate table elements
|
||||||
if (options.editable) {
|
if (options.editable) {
|
||||||
|
|
||||||
|
// Callback for "new bom item" button in footer
|
||||||
|
table.on('click', '#bom-item-new-footer', function() {
|
||||||
|
addBomItem(options.parent_id, {
|
||||||
|
onSuccess: function() {
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Callback for "delete" button
|
// Callback for "delete" button
|
||||||
table.on('click', '.bom-delete-button', function() {
|
table.on('click', '.bom-delete-button', function() {
|
||||||
|
|
||||||
|
|||||||
@@ -1231,6 +1231,8 @@ function loadBuildOutputTable(build_info, options={}) {
|
|||||||
text += ` <small>({% trans "Batch" %}: ${row.batch})</small>`;
|
text += ` <small>({% trans "Batch" %}: ${row.batch})</small>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
text += stockStatusDisplay(row.status, {classes: 'float-right'});
|
||||||
|
|
||||||
return renderLink(text, url);
|
return renderLink(text, url);
|
||||||
},
|
},
|
||||||
sorter: function(a, b, row_a, row_b) {
|
sorter: function(a, b, row_a, row_b) {
|
||||||
|
|||||||
@@ -1230,12 +1230,7 @@ function handleNestedErrors(errors, field_name, options={}) {
|
|||||||
// Find the target (nested) field
|
// Find the target (nested) field
|
||||||
var target = `${field_name}_${sub_field_name}_${nest_id}`;
|
var target = `${field_name}_${sub_field_name}_${nest_id}`;
|
||||||
|
|
||||||
for (var ii = errors.length-1; ii >= 0; ii--) {
|
addFieldErrorMessage(target, errors, options);
|
||||||
|
|
||||||
var error_text = errors[ii];
|
|
||||||
|
|
||||||
addFieldErrorMessage(target, error_text, ii, options);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1312,13 +1307,7 @@ function handleFormErrors(errors, fields={}, options={}) {
|
|||||||
first_error_field = field_name;
|
first_error_field = field_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add an entry for each returned error message
|
addFieldErrorMessage(field_name, field_errors, options);
|
||||||
for (var ii = field_errors.length-1; ii >= 0; ii--) {
|
|
||||||
|
|
||||||
var error_text = field_errors[ii];
|
|
||||||
|
|
||||||
addFieldErrorMessage(field_name, error_text, ii, options);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,6 +1330,16 @@ function handleFormErrors(errors, fields={}, options={}) {
|
|||||||
*/
|
*/
|
||||||
function addFieldErrorMessage(name, error_text, error_idx=0, options={}) {
|
function addFieldErrorMessage(name, error_text, error_idx=0, options={}) {
|
||||||
|
|
||||||
|
// Handle a 'list' of error message recursively
|
||||||
|
if (typeof(error_text) == 'object') {
|
||||||
|
// Iterate backwards through the list
|
||||||
|
for (var ii = error_text.length - 1; ii >= 0; ii--) {
|
||||||
|
addFieldErrorMessage(name, error_text[ii], ii, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
field_name = getFieldName(name, options);
|
field_name = getFieldName(name, options);
|
||||||
|
|
||||||
var field_dom = null;
|
var field_dom = null;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
web: env/bin/gunicorn --chdir $APP_HOME/InvenTree -c InvenTree/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$PORT
|
||||||
|
worker: env/bin/python InvenTree/manage.py qcluster
|
||||||
@@ -136,6 +136,11 @@ There are several options to deploy InvenTree.
|
|||||||
<a href="https://inventree.readthedocs.io/en/latest/start/install/">Bare Metal</a>
|
<a href="https://inventree.readthedocs.io/en/latest/start/install/">Bare Metal</a>
|
||||||
</h4></div>
|
</h4></div>
|
||||||
|
|
||||||
|
Single line install:
|
||||||
|
```bash
|
||||||
|
curl https://raw.githubusercontent.com/InvenTree/InvenTree/master/contrib/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
<!-- Contributing -->
|
<!-- Contributing -->
|
||||||
## :wave: Contributing
|
## :wave: Contributing
|
||||||
|
|
||||||
|
|||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
get_distribution() {
|
||||||
|
lsb_dist=""
|
||||||
|
# Every system that we officially support has /etc/os-release
|
||||||
|
if [ -r /etc/os-release ]; then
|
||||||
|
lsb_dist="$(. /etc/os-release && echo "$ID")"
|
||||||
|
fi
|
||||||
|
# Returning an empty string here should be alright since the
|
||||||
|
# case statements don't act unless you provide an actual value
|
||||||
|
echo "$lsb_dist"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_distribution
|
||||||
|
case "$lsb_dist" in
|
||||||
|
ubuntu)
|
||||||
|
if command_exists lsb_release; then
|
||||||
|
dist_version="$(lsb_release -r | cut -f2)"
|
||||||
|
fi
|
||||||
|
if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then
|
||||||
|
dist_version="$(. /etc/lsb-release && echo "$DISTRIB_RELEASE")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
debian | raspbian)
|
||||||
|
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
|
||||||
|
lsb_dist="debian"
|
||||||
|
;;
|
||||||
|
centos | rhel | sles)
|
||||||
|
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
|
||||||
|
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if command_exists lsb_release; then
|
||||||
|
dist_version="$(lsb_release --release | cut -f2)"
|
||||||
|
fi
|
||||||
|
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
|
||||||
|
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "### ${lsb_dist} ${dist_version} detected"
|
||||||
|
|
||||||
|
# Make sure the depencies are there
|
||||||
|
sudo apt-get install wget apt-transport-https -y
|
||||||
|
|
||||||
|
echo "### Add key and package source"
|
||||||
|
# Add key
|
||||||
|
wget -qO- https://dl.packager.io/srv/matmair/InvenTree/key | sudo apt-key add -
|
||||||
|
# Add packagelist
|
||||||
|
sudo wget -O /etc/apt/sources.list.d/inventree.list https://dl.packager.io/srv/matmair/InvenTree/deploy-test/installer/${lsb_dist}/${dist_version}.repo
|
||||||
|
|
||||||
|
echo "### Install InvenTree"
|
||||||
|
# Update repos and install inventree
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install inventree -y
|
||||||
Executable
+294
@@ -0,0 +1,294 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# packager.io postinstall script functions
|
||||||
|
#
|
||||||
|
|
||||||
|
function detect_docker() {
|
||||||
|
if [ -n "$(grep docker </proc/1/cgroup)" ]; then
|
||||||
|
DOCKER="yes"
|
||||||
|
else
|
||||||
|
DOCKER="no"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function detect_initcmd() {
|
||||||
|
if [ -n "$(which systemctl 2>/dev/null)" ]; then
|
||||||
|
INIT_CMD="systemctl"
|
||||||
|
elif [ -n "$(which initctl 2>/dev/null)" ]; then
|
||||||
|
INIT_CMD="initctl"
|
||||||
|
else
|
||||||
|
function sysvinit() {
|
||||||
|
service $2 $1
|
||||||
|
}
|
||||||
|
INIT_CMD="sysvinit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${DOCKER}" == "yes" ]; then
|
||||||
|
INIT_CMD="initctl"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function detect_ip() {
|
||||||
|
# Get the IP address of the server
|
||||||
|
|
||||||
|
if [ "${SETUP_NO_CALLS}" == "true" ]; then
|
||||||
|
# Use local IP address
|
||||||
|
echo "# Getting the IP address of the first local IP address"
|
||||||
|
export INVENTREE_IP=$(hostname -I | awk '{print $1}')
|
||||||
|
else
|
||||||
|
# Use web service to get the IP address
|
||||||
|
echo "# Getting the IP address of the server via web service"
|
||||||
|
export INVENTREE_IP=$(curl -s https://checkip.amazonaws.com)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "IP address is ${INVENTREE_IP}"
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_env() {
|
||||||
|
envname=$1
|
||||||
|
|
||||||
|
pid=$$
|
||||||
|
while [ -z "${!envname}" -a $pid != 1 ]; do
|
||||||
|
ppid=`ps -oppid -p$pid|tail -1|awk '{print $1}'`
|
||||||
|
env=`strings /proc/$ppid/environ`
|
||||||
|
export $envname=`echo "$env"|awk -F= '$1 == "'$envname'" { print $2; }'`
|
||||||
|
pid=$ppid
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "${SETUP_DEBUG}" ]; then
|
||||||
|
echo "Done getting env $envname: ${!envname}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function detect_local_env() {
|
||||||
|
# Get all possible envs for the install
|
||||||
|
|
||||||
|
if [ -n "${SETUP_DEBUG}" ]; then
|
||||||
|
echo "# Printing local envs - before #++#"
|
||||||
|
printenv
|
||||||
|
fi
|
||||||
|
|
||||||
|
for i in ${SETUP_ENVS//,/ }
|
||||||
|
do
|
||||||
|
get_env $i
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "${SETUP_DEBUG}" ]; then
|
||||||
|
echo "# Printing local envs - after #++#"
|
||||||
|
printenv
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function detect_envs() {
|
||||||
|
# Detect all envs that should be passed to setup commands
|
||||||
|
|
||||||
|
echo "# Setting base environment variables"
|
||||||
|
|
||||||
|
export INVENTREE_CONFIG_FILE=${CONF_DIR}/config.yaml
|
||||||
|
|
||||||
|
if test -f "${INVENTREE_CONFIG_FILE}"; then
|
||||||
|
echo "# Using existing config file: ${INVENTREE_CONFIG_FILE}"
|
||||||
|
|
||||||
|
# Install parser
|
||||||
|
pip install jc -q
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
local conf=$(cat ${INVENTREE_CONFIG_FILE} | jc --yaml)
|
||||||
|
|
||||||
|
# Parse the config file
|
||||||
|
export INVENTREE_MEDIA_ROOT=$conf | jq '.[].media_root'
|
||||||
|
export INVENTREE_STATIC_ROOT=$conf | jq '.[].static_root'
|
||||||
|
export INVENTREE_BACKUP_DIR=$conf | jq '.[].backup_dir'
|
||||||
|
export INVENTREE_PLUGINS_ENABLED=$conf | jq '.[].plugins_enabled'
|
||||||
|
export INVENTREE_PLUGIN_FILE=$conf | jq '.[].plugin_file'
|
||||||
|
export INVENTREE_SECRET_KEY_FILE=$conf | jq '.[].secret_key_file'
|
||||||
|
|
||||||
|
export INVENTREE_DB_ENGINE=$conf | jq '.[].database.ENGINE'
|
||||||
|
export INVENTREE_DB_NAME=$conf | jq '.[].database.NAME'
|
||||||
|
export INVENTREE_DB_USER=$conf | jq '.[].database.USER'
|
||||||
|
export INVENTREE_DB_PASSWORD=$conf | jq '.[].database.PASSWORD'
|
||||||
|
export INVENTREE_DB_HOST=$conf | jq '.[].database.HOST'
|
||||||
|
export INVENTREE_DB_PORT=$conf | jq '.[].database.PORT'
|
||||||
|
else
|
||||||
|
echo "# No config file found: ${INVENTREE_CONFIG_FILE}, using envs or defaults"
|
||||||
|
|
||||||
|
if [ -n "${SETUP_DEBUG}" ]; then
|
||||||
|
echo "# Print current envs"
|
||||||
|
printenv | grep INVENTREE_
|
||||||
|
printenv | grep SETUP_
|
||||||
|
fi
|
||||||
|
|
||||||
|
export INVENTREE_MEDIA_ROOT=${INVENTREE_MEDIA_ROOT:-${DATA_DIR}/media}
|
||||||
|
export INVENTREE_STATIC_ROOT=${DATA_DIR}/static
|
||||||
|
export INVENTREE_BACKUP_DIR=${DATA_DIR}/backup
|
||||||
|
export INVENTREE_PLUGINS_ENABLED=true
|
||||||
|
export INVENTREE_PLUGIN_FILE=${CONF_DIR}/plugins.txt
|
||||||
|
export INVENTREE_SECRET_KEY_FILE=${CONF_DIR}/secret_key.txt
|
||||||
|
|
||||||
|
export INVENTREE_DB_ENGINE=${INVENTREE_DB_ENGINE:-sqlite3}
|
||||||
|
export INVENTREE_DB_NAME=${INVENTREE_DB_NAME:-${DATA_DIR}/database.sqlite3}
|
||||||
|
export INVENTREE_DB_USER=${INVENTREE_DB_USER:-sampleuser}
|
||||||
|
export INVENTREE_DB_PASSWORD=${INVENTREE_DB_PASSWORD:-samplepassword}
|
||||||
|
export INVENTREE_DB_HOST=${INVENTREE_DB_HOST:-samplehost}
|
||||||
|
export INVENTREE_DB_PORT=${INVENTREE_DB_PORT:-sampleport}
|
||||||
|
|
||||||
|
export SETUP_CONF_LOADED=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For debugging pass out the envs
|
||||||
|
echo "# Collected environment variables:"
|
||||||
|
echo "# INVENTREE_MEDIA_ROOT=${INVENTREE_MEDIA_ROOT}"
|
||||||
|
echo "# INVENTREE_STATIC_ROOT=${INVENTREE_STATIC_ROOT}"
|
||||||
|
echo "# INVENTREE_BACKUP_DIR=${INVENTREE_BACKUP_DIR}"
|
||||||
|
echo "# INVENTREE_PLUGINS_ENABLED=${INVENTREE_PLUGINS_ENABLED}"
|
||||||
|
echo "# INVENTREE_PLUGIN_FILE=${INVENTREE_PLUGIN_FILE}"
|
||||||
|
echo "# INVENTREE_SECRET_KEY_FILE=${INVENTREE_SECRET_KEY_FILE}"
|
||||||
|
echo "# INVENTREE_DB_ENGINE=${INVENTREE_DB_ENGINE}"
|
||||||
|
echo "# INVENTREE_DB_NAME=${INVENTREE_DB_NAME}"
|
||||||
|
echo "# INVENTREE_DB_USER=${INVENTREE_DB_USER}"
|
||||||
|
if [ -n "${SETUP_DEBUG}" ]; then
|
||||||
|
echo "# INVENTREE_DB_PASSWORD=${INVENTREE_DB_PASSWORD}"
|
||||||
|
fi
|
||||||
|
echo "# INVENTREE_DB_HOST=${INVENTREE_DB_HOST}"
|
||||||
|
echo "# INVENTREE_DB_PORT=${INVENTREE_DB_PORT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
function create_initscripts() {
|
||||||
|
|
||||||
|
# Make sure python env exsists
|
||||||
|
if test -f "${APP_HOME}/env"; then
|
||||||
|
echo "# python enviroment already present - skipping"
|
||||||
|
else
|
||||||
|
echo "# Setting up python enviroment"
|
||||||
|
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && python3 -m venv env && pip install invoke"
|
||||||
|
|
||||||
|
if [ -n "${SETUP_EXTRA_PIP}" ]; then
|
||||||
|
echo "# Installing extra pip packages"
|
||||||
|
if [ -n "${SETUP_DEBUG}" ]; then
|
||||||
|
echo "# Extra pip packages: ${SETUP_EXTRA_PIP}"
|
||||||
|
fi
|
||||||
|
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && env/bin/pip install ${SETUP_EXTRA_PIP}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unlink default config if it exists
|
||||||
|
if test -f "/etc/nginx/sites-enabled/default"; then
|
||||||
|
echo "# Unlinking default nginx config\n# Old file still in /etc/nginx/sites-available/default"
|
||||||
|
sudo unlink /etc/nginx/sites-enabled/default
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create InvenTree specific nginx config
|
||||||
|
echo "# Stopping nginx"
|
||||||
|
${INIT_CMD} stop nginx
|
||||||
|
echo "# Setting up nginx to ${SETUP_NGINX_FILE}"
|
||||||
|
# Always use the latest nginx config; important if new headers are added / needed for security
|
||||||
|
cp ${APP_HOME}/docker/production/nginx.prod.conf ${SETUP_NGINX_FILE}
|
||||||
|
sed -i s/inventree-server:8000/localhost:6000/g ${SETUP_NGINX_FILE}
|
||||||
|
sed -i s=var/www=opt/inventree/data=g ${SETUP_NGINX_FILE}
|
||||||
|
# Start nginx
|
||||||
|
echo "# Starting nginx"
|
||||||
|
${INIT_CMD} start nginx
|
||||||
|
|
||||||
|
echo "# (Re)creating init scripts"
|
||||||
|
# This reset scale parameters to a known state
|
||||||
|
inventree scale web="1" worker="1"
|
||||||
|
|
||||||
|
echo "# Enabling InvenTree on boot"
|
||||||
|
${INIT_CMD} enable inventree
|
||||||
|
}
|
||||||
|
|
||||||
|
function create_admin() {
|
||||||
|
# Create data for admin user
|
||||||
|
|
||||||
|
if test -f "${SETUP_ADMIN_PASSWORD_FILE}"; then
|
||||||
|
echo "# Admin data already exists - skipping"
|
||||||
|
else
|
||||||
|
echo "# Creating admin user data"
|
||||||
|
|
||||||
|
# Static admin data
|
||||||
|
export INVENTREE_ADMIN_USER=${INVENTREE_ADMIN_USER:-admin}
|
||||||
|
export INVENTREE_ADMIN_EMAIL=${INVENTREE_ADMIN_EMAIL:-admin@example.com}
|
||||||
|
|
||||||
|
# Create password if not set
|
||||||
|
if [ -z "${INVENTREE_ADMIN_PASSWORD}" ]; then
|
||||||
|
openssl rand -base64 32 >${SETUP_ADMIN_PASSWORD_FILE}
|
||||||
|
export INVENTREE_ADMIN_PASSWORD=$(cat ${SETUP_ADMIN_PASSWORD_FILE})
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function start_inventree() {
|
||||||
|
echo "# Starting InvenTree"
|
||||||
|
${INIT_CMD} start inventree
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop_inventree() {
|
||||||
|
echo "# Stopping InvenTree"
|
||||||
|
${INIT_CMD} stop inventree
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_or_install() {
|
||||||
|
|
||||||
|
# Set permissions so app user can write there
|
||||||
|
chown ${APP_USER}:${APP_GROUP} ${APP_HOME} -R
|
||||||
|
|
||||||
|
# Run update as app user
|
||||||
|
echo "# Updating InvenTree"
|
||||||
|
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke update | sed -e 's/^/# inv update| /;'"
|
||||||
|
|
||||||
|
# Make sure permissions are correct again
|
||||||
|
echo "# Set permissions for data dir and media: ${DATA_DIR}"
|
||||||
|
chown ${APP_USER}:${APP_GROUP} ${DATA_DIR} -R
|
||||||
|
chown ${APP_USER}:${APP_GROUP} ${CONF_DIR} -R
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_env() {
|
||||||
|
echo "# Setting up InvenTree config values"
|
||||||
|
|
||||||
|
inventree config:set INVENTREE_CONFIG_FILE=${INVENTREE_CONFIG_FILE}
|
||||||
|
|
||||||
|
# Changing the config file
|
||||||
|
echo "# Writing the settings to the config file ${INVENTREE_CONFIG_FILE}"
|
||||||
|
# Media Root
|
||||||
|
sed -i s=#media_root:\ \'/home/inventree/data/media\'=media_root:\ \'${INVENTREE_MEDIA_ROOT}\'=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Static Root
|
||||||
|
sed -i s=#static_root:\ \'/home/inventree/data/static\'=static_root:\ \'${INVENTREE_STATIC_ROOT}\'=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Backup dir
|
||||||
|
sed -i s=#backup_dir:\ \'/home/inventree/data/backup\'=backup_dir:\ \'${INVENTREE_BACKUP_DIR}\'=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Plugins enabled
|
||||||
|
sed -i s=plugins_enabled:\ False=plugins_enabled:\ ${INVENTREE_PLUGINS_ENABLED}=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Plugin file
|
||||||
|
sed -i s=#plugin_file:\ \'/path/to/plugins.txt\'=plugin_file:\ \'${INVENTREE_PLUGIN_FILE}\'=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Secret key file
|
||||||
|
sed -i s=#secret_key_file:\ \'/etc/inventree/secret_key.txt\'=secret_key_file:\ \'${INVENTREE_SECRET_KEY_FILE}\'=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Debug mode
|
||||||
|
sed -i s=debug:\ True=debug:\ False=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
|
||||||
|
# Database engine
|
||||||
|
sed -i s=#ENGINE:\ sampleengine=ENGINE:\ ${INVENTREE_DB_ENGINE}=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Database name
|
||||||
|
sed -i s=#NAME:\ \'/path/to/database\'=NAME:\ \'${INVENTREE_DB_NAME}\'=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Database user
|
||||||
|
sed -i s=#USER:\ sampleuser=USER:\ ${INVENTREE_DB_USER}=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Database password
|
||||||
|
sed -i s=#PASSWORD:\ samplepassword=PASSWORD:\ ${INVENTREE_DB_PASSWORD}=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Database host
|
||||||
|
sed -i s=#HOST:\ samplehost=HOST:\ ${INVENTREE_DB_HOST}=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Database port
|
||||||
|
sed -i s=#PORT:\ sampleport=PORT:\ ${INVENTREE_DB_PORT}=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
|
||||||
|
# Fixing the permissions
|
||||||
|
chown ${APP_USER}:${APP_GROUP} ${DATA_DIR} ${INVENTREE_CONFIG_FILE}
|
||||||
|
}
|
||||||
|
|
||||||
|
function final_message() {
|
||||||
|
echo -e "####################################################################################"
|
||||||
|
echo -e "This InvenTree install uses nginx, the settings for the webserver can be found in"
|
||||||
|
echo -e "${SETUP_NGINX_FILE}"
|
||||||
|
echo -e "Try opening InvenTree with either\nhttp://localhost/ or http://${INVENTREE_IP}/\n"
|
||||||
|
echo -e "Admin user data:"
|
||||||
|
echo -e " Email: ${INVENTREE_ADMIN_EMAIL}"
|
||||||
|
echo -e " Username: ${INVENTREE_ADMIN_USER}"
|
||||||
|
echo -e " Password: ${INVENTREE_ADMIN_PASSWORD}"
|
||||||
|
echo -e "####################################################################################"
|
||||||
|
}
|
||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# packager.io postinstall script
|
||||||
|
#
|
||||||
|
|
||||||
|
exec > >(tee ${APP_HOME}/log/setup_$(date +"%F_%H_%M_%S").log) 2>&1
|
||||||
|
|
||||||
|
PATH=${APP_HOME}/env/bin:${APP_HOME}/:/sbin:/bin:/usr/sbin:/usr/bin:
|
||||||
|
|
||||||
|
# import functions
|
||||||
|
. ${APP_HOME}/contrib/packager.io/functions.sh
|
||||||
|
|
||||||
|
# Envs that should be passed to setup commands
|
||||||
|
export SETUP_ENVS=PATH,APP_HOME,INVENTREE_MEDIA_ROOT,INVENTREE_STATIC_ROOT,INVENTREE_PLUGINS_ENABLED,INVENTREE_PLUGIN_FILE,INVENTREE_CONFIG_FILE,INVENTREE_SECRET_KEY_FILE,INVENTREE_DB_ENGINE,INVENTREE_DB_NAME,INVENTREE_DB_USER,INVENTREE_DB_PASSWORD,INVENTREE_DB_HOST,INVENTREE_DB_PORT,INVENTREE_ADMIN_USER,INVENTREE_ADMIN_EMAIL,INVENTREE_ADMIN_PASSWORD,SETUP_NGINX_FILE,SETUP_ADMIN_PASSWORD_FILE,SETUP_NO_CALLS,SETUP_DEBUG,SETUP_EXTRA_PIP
|
||||||
|
|
||||||
|
# Get the envs
|
||||||
|
detect_local_env
|
||||||
|
|
||||||
|
# default config
|
||||||
|
export CONF_DIR=/etc/inventree
|
||||||
|
export DATA_DIR=${APP_HOME}/data
|
||||||
|
# Setup variables
|
||||||
|
export SETUP_NGINX_FILE=${SETUP_NGINX_FILE:-/etc/nginx/sites-enabled/inventree.conf}
|
||||||
|
export SETUP_ADMIN_PASSWORD_FILE=${CONF_DIR}/admin_password.txt
|
||||||
|
export SETUP_NO_CALLS=${SETUP_NO_CALLS:-false}
|
||||||
|
# SETUP_DEBUG can be set to get debug info
|
||||||
|
# SETUP_EXTRA_PIP can be set to install extra pip packages
|
||||||
|
|
||||||
|
# get base info
|
||||||
|
detect_envs
|
||||||
|
detect_docker
|
||||||
|
detect_initcmd
|
||||||
|
detect_ip
|
||||||
|
|
||||||
|
# create processes
|
||||||
|
create_initscripts
|
||||||
|
create_admin
|
||||||
|
|
||||||
|
# run updates
|
||||||
|
stop_inventree
|
||||||
|
update_or_install
|
||||||
|
# Write config file
|
||||||
|
if [ "${SETUP_CONF_LOADED}" = "true" ]; then
|
||||||
|
set_env
|
||||||
|
fi
|
||||||
|
start_inventree
|
||||||
|
|
||||||
|
# show info
|
||||||
|
final_message
|
||||||
@@ -13,6 +13,11 @@ if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
|
|||||||
mkdir -p $INVENTREE_MEDIA_ROOT
|
mkdir -p $INVENTREE_MEDIA_ROOT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$INVENTREE_BACKUP_DIR" ]]; then
|
||||||
|
echo "Creating directory $INVENTREE_BACKUP_DIR"
|
||||||
|
mkdir -p $INVENTREE_BACKUP_DIR
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if "config.yaml" has been copied into the correct location
|
# Check if "config.yaml" has been copied into the correct location
|
||||||
if test -f "$INVENTREE_CONFIG_FILE"; then
|
if test -f "$INVENTREE_CONFIG_FILE"; then
|
||||||
echo "$INVENTREE_CONFIG_FILE exists - skipping"
|
echo "$INVENTREE_CONFIG_FILE exists - skipping"
|
||||||
|
|||||||
+21
-21
@@ -14,9 +14,9 @@ build==0.8.0 \
|
|||||||
--hash=sha256:19b0ed489f92ace6947698c3ca8436cb0556a66e2aa2d34cd70e2a5d27cd0437 \
|
--hash=sha256:19b0ed489f92ace6947698c3ca8436cb0556a66e2aa2d34cd70e2a5d27cd0437 \
|
||||||
--hash=sha256:887a6d471c901b1a6e6574ebaeeebb45e5269a79d095fe9a8f88d6614ed2e5f0
|
--hash=sha256:887a6d471c901b1a6e6574ebaeeebb45e5269a79d095fe9a8f88d6614ed2e5f0
|
||||||
# via pip-tools
|
# via pip-tools
|
||||||
certifi==2022.6.15 \
|
certifi==2022.9.24 \
|
||||||
--hash=sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d \
|
--hash=sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14 \
|
||||||
--hash=sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412
|
--hash=sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# requests
|
# requests
|
||||||
@@ -24,9 +24,9 @@ cfgv==3.3.1 \
|
|||||||
--hash=sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426 \
|
--hash=sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426 \
|
||||||
--hash=sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736
|
--hash=sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736
|
||||||
# via pre-commit
|
# via pre-commit
|
||||||
charset-normalizer==2.1.0 \
|
charset-normalizer==2.1.1 \
|
||||||
--hash=sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5 \
|
--hash=sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845 \
|
||||||
--hash=sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413
|
--hash=sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# requests
|
# requests
|
||||||
@@ -98,9 +98,9 @@ distlib==0.3.5 \
|
|||||||
--hash=sha256:a7f75737c70be3b25e2bee06288cec4e4c221de18455b2dd037fe2a795cab2fe \
|
--hash=sha256:a7f75737c70be3b25e2bee06288cec4e4c221de18455b2dd037fe2a795cab2fe \
|
||||||
--hash=sha256:b710088c59f06338ca514800ad795a132da19fda270e3ce4affc74abf955a26c
|
--hash=sha256:b710088c59f06338ca514800ad795a132da19fda270e3ce4affc74abf955a26c
|
||||||
# via virtualenv
|
# via virtualenv
|
||||||
django==3.2.15 \
|
django==3.2.16 \
|
||||||
--hash=sha256:115baf5049d5cf4163e43492cdc7139c306ed6d451e7d3571fe9612903903713 \
|
--hash=sha256:18ba8efa36b69cfcd4b670d0fa187c6fe7506596f0ababe580e16909bcdec121 \
|
||||||
--hash=sha256:f71934b1a822f14a86c9ac9634053689279cd04ae69cb6ade4a59471b886582b
|
--hash=sha256:3adc285124244724a394fa9b9839cc8cd116faf7d159554c43ecdaa8cdf0b94d
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# django-debug-toolbar
|
# django-debug-toolbar
|
||||||
@@ -134,9 +134,9 @@ identify==2.5.3 \
|
|||||||
--hash=sha256:25851c8c1370effb22aaa3c987b30449e9ff0cece408f810ae6ce408fdd20893 \
|
--hash=sha256:25851c8c1370effb22aaa3c987b30449e9ff0cece408f810ae6ce408fdd20893 \
|
||||||
--hash=sha256:887e7b91a1be152b0d46bbf072130235a8117392b9f1828446079a816a05ef44
|
--hash=sha256:887e7b91a1be152b0d46bbf072130235a8117392b9f1828446079a816a05ef44
|
||||||
# via pre-commit
|
# via pre-commit
|
||||||
idna==3.3 \
|
idna==3.4 \
|
||||||
--hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \
|
--hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \
|
||||||
--hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d
|
--hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# requests
|
# requests
|
||||||
@@ -192,9 +192,9 @@ pyparsing==3.0.9 \
|
|||||||
--hash=sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb \
|
--hash=sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb \
|
||||||
--hash=sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc
|
--hash=sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc
|
||||||
# via packaging
|
# via packaging
|
||||||
pytz==2022.1 \
|
pytz==2022.4 \
|
||||||
--hash=sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7 \
|
--hash=sha256:2c0784747071402c6e99f0bafdb7da0fa22645f06554c7ae06bf6358897e9c91 \
|
||||||
--hash=sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c
|
--hash=sha256:48ce799d83b6f8aab2020e369b627446696619e79645419610b9facd909b3174
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# django
|
# django
|
||||||
@@ -245,9 +245,9 @@ snowballstemmer==2.2.0 \
|
|||||||
--hash=sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1 \
|
--hash=sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1 \
|
||||||
--hash=sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a
|
--hash=sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a
|
||||||
# via pydocstyle
|
# via pydocstyle
|
||||||
sqlparse==0.4.2 \
|
sqlparse==0.4.3 \
|
||||||
--hash=sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae \
|
--hash=sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34 \
|
||||||
--hash=sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d
|
--hash=sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# django
|
# django
|
||||||
@@ -266,9 +266,9 @@ typing-extensions==4.3.0 \
|
|||||||
--hash=sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02 \
|
--hash=sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02 \
|
||||||
--hash=sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6
|
--hash=sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6
|
||||||
# via django-test-migrations
|
# via django-test-migrations
|
||||||
urllib3==1.26.11 \
|
urllib3==1.26.12 \
|
||||||
--hash=sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc \
|
--hash=sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e \
|
||||||
--hash=sha256:ea6e8fb210b19d950fab93b60c9009226c63a28808bc8386e05301e25883ac0a
|
--hash=sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# requests
|
# requests
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ django-allauth-2fa # MFA / 2FA
|
|||||||
django-cleanup # Automated deletion of old / unused uploaded files
|
django-cleanup # Automated deletion of old / unused uploaded files
|
||||||
django-cors-headers # CORS headers extension for DRF
|
django-cors-headers # CORS headers extension for DRF
|
||||||
django-crispy-forms # Form helpers
|
django-crispy-forms # Form helpers
|
||||||
|
django-dbbackup # Backup / restore of database and media files
|
||||||
django-error-report # Error report viewer for the admin interface
|
django-error-report # Error report viewer for the admin interface
|
||||||
django-filter # Extended filtering options
|
django-filter # Extended filtering options
|
||||||
django-formtools # Form wizard tools
|
django-formtools # Form wizard tools
|
||||||
|
|||||||
+28
-22
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
# pip-compile --output-file=requirements.txt requirements.in
|
# pip-compile --output-file=requirements.txt requirements.in
|
||||||
#
|
#
|
||||||
arrow==1.2.2
|
arrow==1.2.3
|
||||||
# via django-q
|
# via django-q
|
||||||
asgiref==3.5.2
|
asgiref==3.5.2
|
||||||
# via django
|
# via django
|
||||||
@@ -16,7 +16,7 @@ blessed==1.19.1
|
|||||||
# via django-q
|
# via django-q
|
||||||
brotli==1.0.9
|
brotli==1.0.9
|
||||||
# via fonttools
|
# via fonttools
|
||||||
certifi==2022.6.15
|
certifi==2022.9.24
|
||||||
# via
|
# via
|
||||||
# requests
|
# requests
|
||||||
# sentry-sdk
|
# sentry-sdk
|
||||||
@@ -24,7 +24,7 @@ cffi==1.15.1
|
|||||||
# via
|
# via
|
||||||
# cryptography
|
# cryptography
|
||||||
# weasyprint
|
# weasyprint
|
||||||
charset-normalizer==2.1.0
|
charset-normalizer==2.1.1
|
||||||
# via requests
|
# via requests
|
||||||
coreapi==2.3.3
|
coreapi==2.3.3
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
@@ -34,7 +34,7 @@ cryptography==3.4.8
|
|||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# pyjwt
|
# pyjwt
|
||||||
cssselect2==0.6.0
|
cssselect2==0.7.0
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
defusedxml==0.7.1
|
defusedxml==0.7.1
|
||||||
# via
|
# via
|
||||||
@@ -42,12 +42,13 @@ defusedxml==0.7.1
|
|||||||
# python3-openid
|
# python3-openid
|
||||||
diff-match-patch==20200713
|
diff-match-patch==20200713
|
||||||
# via django-import-export
|
# via django-import-export
|
||||||
django==3.2.15
|
django==3.2.16
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# django-allauth
|
# django-allauth
|
||||||
# django-allauth-2fa
|
# django-allauth-2fa
|
||||||
# django-cors-headers
|
# django-cors-headers
|
||||||
|
# django-dbbackup
|
||||||
# django-error-report
|
# django-error-report
|
||||||
# django-filter
|
# django-filter
|
||||||
# django-formtools
|
# django-formtools
|
||||||
@@ -79,11 +80,13 @@ django-cors-headers==3.13.0
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-crispy-forms==1.14.0
|
django-crispy-forms==1.14.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
django-dbbackup==4.0.2
|
||||||
|
# via -r requirements.in
|
||||||
django-error-report==0.2.0
|
django-error-report==0.2.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-filter==22.1
|
django-filter==22.1
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-formtools==2.3
|
django-formtools==2.4
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-import-export==2.5.0
|
django-import-export==2.5.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
@@ -113,23 +116,23 @@ django-stdimage==5.3.0
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-user-sessions==1.7.1
|
django-user-sessions==1.7.1
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-weasyprint==2.1.0
|
django-weasyprint==2.2.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-xforwardedfor-middleware==2.0
|
django-xforwardedfor-middleware==2.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
djangorestframework==3.13.1
|
djangorestframework==3.14.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
et-xmlfile==1.1.0
|
et-xmlfile==1.1.0
|
||||||
# via openpyxl
|
# via openpyxl
|
||||||
fonttools[woff]==4.34.4
|
fonttools[woff]==4.37.4
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
html5lib==1.1
|
html5lib==1.1
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
idna==3.3
|
idna==3.4
|
||||||
# via requests
|
# via requests
|
||||||
importlib-metadata==4.12.0
|
importlib-metadata==5.0.0
|
||||||
# via markdown
|
# via markdown
|
||||||
itypes==1.2.0
|
itypes==1.2.0
|
||||||
# via coreapi
|
# via coreapi
|
||||||
@@ -141,7 +144,7 @@ markuppy==1.14
|
|||||||
# via tablib
|
# via tablib
|
||||||
markupsafe==2.1.1
|
markupsafe==2.1.1
|
||||||
# via jinja2
|
# via jinja2
|
||||||
oauthlib==3.2.0
|
oauthlib==3.2.1
|
||||||
# via requests-oauthlib
|
# via requests-oauthlib
|
||||||
odfpy==1.4.1
|
odfpy==1.4.1
|
||||||
# via tablib
|
# via tablib
|
||||||
@@ -163,24 +166,25 @@ py-moneyed==1.2
|
|||||||
# django-money
|
# django-money
|
||||||
pycparser==2.21
|
pycparser==2.21
|
||||||
# via cffi
|
# via cffi
|
||||||
pydyf==0.2.0
|
pydyf==0.5.0
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
pyjwt[crypto]==2.4.0
|
pyjwt[crypto]==2.5.0
|
||||||
# via django-allauth
|
# via django-allauth
|
||||||
pyphen==0.12.0
|
pyphen==0.13.0
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
python-barcode[images]==0.14.0
|
python-barcode[images]==0.14.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
# via arrow
|
# via arrow
|
||||||
python-fsutil==0.6.1
|
python-fsutil==0.7.0
|
||||||
# via django-maintenance-mode
|
# via django-maintenance-mode
|
||||||
python3-openid==3.2.0
|
python3-openid==3.2.0
|
||||||
# via django-allauth
|
# via django-allauth
|
||||||
pytz==2022.1
|
pytz==2022.4
|
||||||
# via
|
# via
|
||||||
# babel
|
# babel
|
||||||
# django
|
# django
|
||||||
|
# django-dbbackup
|
||||||
# djangorestframework
|
# djangorestframework
|
||||||
pyyaml==6.0
|
pyyaml==6.0
|
||||||
# via tablib
|
# via tablib
|
||||||
@@ -194,7 +198,7 @@ redis==3.5.3
|
|||||||
# via
|
# via
|
||||||
# django-q
|
# django-q
|
||||||
# django-redis
|
# django-redis
|
||||||
regex==2022.8.17
|
regex==2022.9.13
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
requests==2.28.1
|
requests==2.28.1
|
||||||
# via
|
# via
|
||||||
@@ -203,7 +207,7 @@ requests==2.28.1
|
|||||||
# requests-oauthlib
|
# requests-oauthlib
|
||||||
requests-oauthlib==1.3.1
|
requests-oauthlib==1.3.1
|
||||||
# via django-allauth
|
# via django-allauth
|
||||||
sentry-sdk==1.9.0
|
sentry-sdk==1.9.10
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
# via
|
# via
|
||||||
@@ -211,7 +215,7 @@ six==1.16.0
|
|||||||
# blessed
|
# blessed
|
||||||
# html5lib
|
# html5lib
|
||||||
# python-dateutil
|
# python-dateutil
|
||||||
sqlparse==0.4.2
|
sqlparse==0.4.3
|
||||||
# via
|
# via
|
||||||
# django
|
# django
|
||||||
# django-sql-utils
|
# django-sql-utils
|
||||||
@@ -224,9 +228,11 @@ tinycss2==1.1.1
|
|||||||
# bleach
|
# bleach
|
||||||
# cssselect2
|
# cssselect2
|
||||||
# weasyprint
|
# weasyprint
|
||||||
|
types-cryptography==3.3.23
|
||||||
|
# via pyjwt
|
||||||
uritemplate==4.1.1
|
uritemplate==4.1.1
|
||||||
# via coreapi
|
# via coreapi
|
||||||
urllib3==1.26.11
|
urllib3==1.26.12
|
||||||
# via
|
# via
|
||||||
# requests
|
# requests
|
||||||
# sentry-sdk
|
# sentry-sdk
|
||||||
@@ -246,7 +252,7 @@ xlrd==2.0.1
|
|||||||
# via tablib
|
# via tablib
|
||||||
xlwt==1.3.0
|
xlwt==1.3.0
|
||||||
# via tablib
|
# via tablib
|
||||||
zipp==3.8.1
|
zipp==3.9.0
|
||||||
# via importlib-metadata
|
# via importlib-metadata
|
||||||
zopfli==0.2.1
|
zopfli==0.2.1
|
||||||
# via fonttools
|
# via fonttools
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
python-3.10.7
|
||||||
@@ -196,7 +196,27 @@ def translate(c):
|
|||||||
manage(c, "compilemessages")
|
manage(c, "compilemessages")
|
||||||
|
|
||||||
|
|
||||||
@task(post=[rebuild_models, rebuild_thumbnails])
|
@task
|
||||||
|
def backup(c):
|
||||||
|
"""Backup the database and media files."""
|
||||||
|
|
||||||
|
print("Backing up InvenTree database...")
|
||||||
|
manage(c, "dbbackup --noinput --clean --compress")
|
||||||
|
print("Backing up InvenTree media files...")
|
||||||
|
manage(c, "mediabackup --noinput --clean --compress")
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def restore(c):
|
||||||
|
"""Restore the database and media files."""
|
||||||
|
|
||||||
|
print("Restoring InvenTree database...")
|
||||||
|
manage(c, "dbrestore --noinput --uncompress")
|
||||||
|
print("Restoring InvenTree media files...")
|
||||||
|
manage(c, "mediarestore --noinput --uncompress")
|
||||||
|
|
||||||
|
|
||||||
|
@task(pre=[backup, ], post=[rebuild_models, rebuild_thumbnails])
|
||||||
def migrate(c):
|
def migrate(c):
|
||||||
"""Performs database migrations.
|
"""Performs database migrations.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user