2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-23 09:35:30 +00:00

Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters
2022-10-20 21:18:27 +11:00
97 changed files with 21361 additions and 20276 deletions
+1
View File
@@ -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",
+6
View File
@@ -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
+3 -3
View File
@@ -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'
+2 -1
View File
@@ -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
+7 -7
View File
@@ -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
+13 -12
View File
@@ -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:
+4 -4
View File
@@ -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 }}
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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
+2 -2
View File
@@ -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
+2
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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."""
+4 -1
View File
@@ -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
View File
@@ -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.
+29
View File
@@ -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
+2 -3
View File
@@ -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
View File
@@ -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):
+6
View File
@@ -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:
+8
View File
@@ -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%;
} }
+90
View File
@@ -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:
+31 -26
View File
@@ -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):
+47 -17
View File
@@ -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):
+7 -4
View File
@@ -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])
+1
View File
@@ -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
+1 -1
View File
@@ -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)
+2 -1
View File
@@ -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)
+14
View File
@@ -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'),
+23 -1
View File
@@ -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.
+3
View File
@@ -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.
+5 -8
View File
@@ -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'})
+16 -2
View File
@@ -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
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
+10 -2
View File
@@ -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,
+3 -1
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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):
+2 -20
View File
@@ -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');
} }
+4 -2
View File
@@ -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
View File
@@ -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}'")
+24 -1
View File
@@ -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'
+133
View File
@@ -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={
+3 -1
View File
@@ -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',
] ]
+31 -3
View File
@@ -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}"
+9 -41
View File
@@ -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
View File
@@ -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:
+12
View File
@@ -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
View File
@@ -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:
+6 -2
View File
@@ -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(
+1 -1
View File
@@ -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
View File
@@ -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" %}
+41
View File
@@ -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) {
+12 -13
View File
@@ -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;
+2
View File
@@ -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
+5
View File
@@ -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
+54
View File
@@ -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
+294
View File
@@ -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 "####################################################################################"
}
+49
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
python-3.10.7
+21 -1
View File
@@ -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.