2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-23 01:25:45 +00:00

Merge remote-tracking branch 'inventree/master'

This commit is contained in:
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_MEDIA_ROOT": "/workspaces/InvenTree/dev/media",
"INVENTREE_STATIC_ROOT": "/workspaces/InvenTree/dev/static",
"INVENTREE_BACKUP_DIR": "/workspaces/InvenTree/dev/backup",
"INVENTREE_CONFIG_FILE": "/workspaces/InvenTree/dev/config.yaml",
"INVENTREE_SECRET_KEY_FILE": "/workspaces/InvenTree/dev/secret_key.txt",
"INVENTREE_PLUGIN_DIR": "/workspaces/InvenTree/dev/plugins",
+6
View File
@@ -4,3 +4,9 @@
# plugins are co-owned
/InvenTree/plugin/ @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'
steps:
- name: Checkout Code
uses: actions/checkout@v2
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
# Python installs
- name: Set up Python ${{ env.python_version }}
if: ${{ inputs.python == 'true' }}
uses: actions/setup-python@v2
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
with:
python-version: ${{ env.python_version }}
cache: pip
@@ -58,7 +58,7 @@ runs:
# NPM installs
- name: Install node.js ${{ env.node_version }}
if: ${{ inputs.npm == 'true' }}
uses: actions/setup-node@v2
uses: actions/setup-node@969bd2663942d722d85b6a8626225850c2f7be4b # pin to v3.5.0
with:
node-version: ${{ env.node_version }}
cache: 'npm'
+2 -1
View File
@@ -20,10 +20,11 @@ jobs:
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
INVENTREE_BACKUP_DIR: ./backup
steps:
- name: Checkout Code
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Install Dependencies
run: |
sudo apt-get update
+7 -7
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Version Check
run: |
pip install requests
@@ -66,30 +66,30 @@ jobs:
test -f data/secret_key.txt
- name: Set up QEMU
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
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
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
if: github.event_name != 'pull_request'
uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7 # pin@v1
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a # pin@v2.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract Docker metadata
if: github.event_name != 'pull_request'
id: meta
uses: docker/metadata-action@69f6fc9d46f2f8bf0d5491e4aabe0bb8c6a4678a # pin@v4.0.1
uses: docker/metadata-action@12cce9efe0d49980455aaaca9b071c0befcdd702 # pin@v4.1.0
with:
images: |
inventree/inventree
- name: Build and Push
id: build-and-push
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:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
+13 -12
View File
@@ -22,6 +22,7 @@ env:
INVENTREE_DB_NAME: inventree
INVENTREE_MEDIA_ROOT: ../test_inventree_media
INVENTREE_STATIC_ROOT: ../test_inventree_static
INVENTREE_BACKUP_DIR: ../test_inventree_backup
jobs:
pep_style:
@@ -29,7 +30,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
@@ -44,7 +45,7 @@ jobs:
needs: pep_style
steps:
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
@@ -66,7 +67,7 @@ jobs:
needs: pep_style
steps:
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
@@ -82,14 +83,14 @@ jobs:
needs: pep_style
steps:
- uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@7f80679172b057fc5e90d70d197929d454754a5a # pin@v2
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- 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
run: |
pip install requests
@@ -113,7 +114,7 @@ jobs:
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
steps:
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
@@ -143,7 +144,7 @@ jobs:
continue-on-error: true
steps:
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
@@ -164,7 +165,7 @@ jobs:
INVENTREE_PLUGINS_ENABLED: true
steps:
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
@@ -199,7 +200,7 @@ jobs:
services:
postgres:
image: postgres
image: postgres:14
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
@@ -212,7 +213,7 @@ jobs:
- 6379:6379
steps:
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
@@ -257,7 +258,7 @@ jobs:
- 3306:3306
steps:
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
+4 -4
View File
@@ -11,13 +11,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Version Check
run: |
pip install requests
python3 ci/version_check.py
- 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'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -27,7 +27,7 @@ jobs:
tweet:
runs-on: ubuntu-latest
steps:
- uses: Eomm/why-don-t-you-tweet@f61f2a86c30c46528c1398a1abb1f64aa0988f69 # pin@v1
- uses: Eomm/why-don-t-you-tweet@5936bb1fd0096b1c2bbbb7518746638261bb4dae # pin@v1.0.1
with:
tweet-message: "InvenTree release ${{ github.event.release.tag_name }} is out
now! Release notes: ${{ github.event.release.html_url }} #opensource
@@ -41,7 +41,7 @@ jobs:
reddit:
runs-on: ubuntu-latest
steps:
- uses: bluwy/release-for-reddit-action@4d948192aff856da22f19f9806b00b46ca384547 # pin@v1
- uses: bluwy/release-for-reddit-action@4b2d034b5c86a24db24363f1064149a8c2db69b4 # pin@v1.2.0
with:
username: ${{ secrets.REDDIT_USERNAME }}
password: ${{ secrets.REDDIT_PASSWORD }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@98ed4cb500039dbcccf4bd9bedada4d0187f2757 # pin@v3
- uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # pin@v6.0.1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
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_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
INVENTREE_BACKUP_DIR: ./backup
steps:
- name: Checkout Code
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Set up Python 3.9
uses: actions/setup-python@152ba7c4dd6521b8e9c93f72d362ce03bf6c4f20 # pin@v1
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
with:
python-version: 3.9
- name: Install Dependencies
@@ -42,7 +43,7 @@ jobs:
git add "*.po"
git commit -m "updated translation base"
- name: Push changes
uses: ad-m/github-push-action@9a46ba8d86d3171233e861a4351b1278a2805c83 # pin@master
uses: ad-m/github-push-action@4dcce6dea3e3c8187237fc86b7dfdc93e5aaae58 # pin@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: l10
+2 -2
View File
@@ -9,7 +9,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Setup
run: pip install -r requirements-dev.txt
- name: Update requirements.txt
@@ -17,7 +17,7 @@ jobs:
- name: Update requirements-dev.txt
run: pip-compile --generate-hashes --output-file=requirements-dev.txt
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:
commit_message: "[Bot] Updated dependency"
branch: dep-update
+2
View File
@@ -5,6 +5,7 @@ tasks:
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
export PIP_USER='no'
sudo apt install -y gettext
@@ -24,6 +25,7 @@ tasks:
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
source venv/bin/activate
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_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
ENV INVENTREE_BACKUP_DIR="${INVENTREE_DATA_DIR}/backup"
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
# InvenTree configuration files
@@ -67,7 +68,7 @@ RUN apt-get install -y --no-install-recommends \
# SQLite support
sqlite3 \
# PostgreSQL support
libpq-dev \
libpq-dev postgresql-client \
# MySQL / MariaDB support
default-libmysqlclient-dev mariadb-client && \
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 plugin import registry
from plugin.models import PluginConfig
class UserMixin:
"""Mixin to setup a user and login for tests.
@@ -87,6 +90,21 @@ class UserMixin:
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):
"""Base class for running InvenTree API tests."""
+4 -1
View File
@@ -2,11 +2,14 @@
# 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
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
- Refactor of barcode data on the API
- StockItem.uid renamed to StockItem.barcode_hash
+21 -55
View File
@@ -1,8 +1,10 @@
"""AppConfig for inventree app."""
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.contrib.auth import get_user_model
from django.core.exceptions import AppRegistryNotReady
@@ -23,10 +25,11 @@ class InvenTreeConfig(AppConfig):
def ready(self):
"""Setup background tasks and update exchange rates."""
if canAppAccessDatabase():
if canAppAccessDatabase() or settings.TESTING_ENV:
self.remove_obsolete_tasks()
self.collect_tasks()
self.start_background_tasks()
if not isInTestMode(): # pragma: no cover
@@ -54,68 +57,31 @@ class InvenTreeConfig(AppConfig):
def start_background_tasks(self):
"""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...")
# 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.delete_successful_tasks',
schedule_type=Schedule.DAILY,
ref_name,
schedule_type=task.interval,
minutes=task.minutes,
)
# Check for InvenTree updates
InvenTree.tasks.schedule_task(
'InvenTree.tasks.check_for_updates',
schedule_type=Schedule.DAILY
)
logger.info("Started background tasks...")
# Heartbeat to let the server know the background worker is running
InvenTree.tasks.schedule_task(
'InvenTree.tasks.heartbeat',
schedule_type=Schedule.MINUTES,
minutes=15
)
def collect_tasks(self):
"""Collect all background tasks."""
# Keep exchange rates up to date
InvenTree.tasks.schedule_task(
'InvenTree.tasks.update_exchange_rates',
schedule_type=Schedule.DAILY,
)
for app_name, app in apps.app_configs.items():
if app_name == 'InvenTree':
continue
# Delete old error messages
InvenTree.tasks.schedule_task(
'InvenTree.tasks.delete_old_error_logs',
schedule_type=Schedule.DAILY,
)
# 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
)
if Path(app.path).joinpath('tasks.py').exists():
try:
import_module(f'{app.module.__package__}.tasks')
except Exception as e: # pragma: no cover
logger.error(f"Error loading tasks for {app_name}: {e}")
def update_exchange_rates(self): # pragma: no cover
"""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()
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:
"""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:
print("InvenTree configuration file 'config.yaml' not found - creating default file")
ensure_dir(cfg_filename.parent)
cfg_template = base_dir.joinpath("config_template.yaml")
shutil.copyfile(cfg_template, cfg_filename)
@@ -149,6 +160,22 @@ def get_static_dir(create=True):
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():
"""Returns the path of the InvenTree plugins specification file.
@@ -169,6 +196,7 @@ def get_plugin_file():
if not plugin_file.exists():
logger.warning("Plugin configuration file does not exist - creating default 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
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():
logger.info(f"Generating random key file at '{secret_key_file}'")
ensure_dir(secret_key_file.parent)
# Create a random key file
options = string.digits + string.ascii_letters + string.punctuation
+2 -3
View File
@@ -4,7 +4,6 @@ import sys
from decimal import Decimal
from django import forms
from django.core import validators
from django.db import models as models
from django.utils.translation import gettext_lazy as _
@@ -15,7 +14,7 @@ from rest_framework.fields import URLField as RestURLField
import InvenTree.helpers
from .validators import allowable_url_schemes
from .validators import AllowedURLValidator, allowable_url_schemes
class InvenTreeRestURLField(RestURLField):
@@ -34,7 +33,7 @@ class InvenTreeRestURLField(RestURLField):
class InvenTreeURLField(models.URLField):
"""Custom URL field which has custom scheme validators."""
default_validators = [validators.URLValidator(schemes=allowable_url_schemes())]
default_validators = [AllowedURLValidator()]
def __init__(self, **kwargs):
"""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()
def increment(n):
def increment(value):
"""Attempt to increment an integer (or a string that looks like an integer).
e.g.
@@ -351,12 +351,14 @@ def increment(n):
2 -> 3
AB01 -> AB02
QQQ -> QQQ
"""
value = str(n).strip()
value = str(value).strip()
# Ignore empty strings
if not value:
return value
if value in ['', None]:
# Provide a default value if provided with a null input
return '1'
pattern = r"(.*?)(\d+)?$"
@@ -542,138 +544,211 @@ def DownloadFile(data, filename, content_type='application/text', inline=False)
return response
def extract_serial_numbers(serials, expected_quantity, next_number: int):
"""Attempt to extract serial numbers from an input string.
def increment_serial_number(serial: str):
"""Given a serial number, (attempt to) generate the *next* serial number.
Requirements:
- Serial numbers can be either strings, or integers
- Serial numbers can be split by whitespace / newline / commma chars
- Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
- Serial numbers can be defined as ~ for getting the next available serial number
Note: This method is exposed to custom plugins.
Arguments:
serial: The serial number which should be incremented
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>+<length> for getting <length> numbers starting from <start>
Args:
serials: input string with patterns
Actual generation of sequential serials is passed to the 'validation' plugin mixin,
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
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
while '~' in serials:
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)
if starting_value is None:
starting_value = increment_serial_number(None)
try:
expected_quantity = int(expected_quantity)
except ValueError:
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")])
# If the user has supplied the correct number of serials, don't process them for groups
# just add them so any duplicates (or future validations) are checked
next_value = increment_serial_number(starting_value)
# 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:
for group in groups:
add_sn(group)
add_serial(group)
if len(errors) > 0:
raise ValidationError(errors)
return numbers
else:
return serials
for group in groups:
group = group.strip()
# Hyphen indicates a range of numbers
if '-' in group:
"""Hyphen indicates a range of values:
e.g. 10-20
"""
items = group.split('-')
if len(items) == 2 and all([i.isnumeric() for i in items]):
a = items[0].strip()
b = items[1].strip()
if len(items) == 2:
a = items[0]
b = items[1]
try:
a = int(a)
b = int(b)
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))
if a == b:
# Invalid group
add_error(_("Invalid group range: {g}").format(g=group))
continue
else:
# More than 2 hyphens or non-numeric group so add without interpolating
add_sn(group)
# plus signals either
# 1: 'start+': expected number of serials, starting at start
# 2: 'start+number': number of serials, starting at start
group_items = []
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:
"""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('+')
# case 1, 2
if len(items) == 2:
start = int(items[0])
sequence_items = []
counter = 0
sequence_count = max(0, expected_quantity - len(serials))
# case 2
if bool(items[1]):
end = start + int(items[1]) + 1
if len(items) > 2 or len(items) == 0:
add_error(_("Invalid group sequence: {g}").format(g=group))
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:
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:
errors.append(_("Invalid group sequence: {g}").format(g=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}"))
# At this point, we assume that the 'group' is just a single serial value
add_serial(group)
if len(errors) > 0:
raise ValidationError(errors)
if len(numbers) == 0:
if len(serials) == 0:
raise ValidationError([_("No serial numbers found")])
# The number of extracted serial numbers must match the expected quantity
if expected_quantity != len(numbers):
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
if len(serials) != expected_quantity:
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(serials), q=expected_quantity)])
return numbers
return serials
def validateFilterString(value, model=None):
+6
View File
@@ -35,6 +35,12 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False):
'collectstatic',
'makemessages',
'compilemessages',
'backup',
'dbbackup',
'mediabackup',
'restore',
'dbrestore',
'mediarestore',
]
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
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
INSTALLED_APPS = [
@@ -176,6 +181,7 @@ INSTALLED_APPS = [
'error_report', # Error reporting in the admin interface
'django_q',
'formtools', # Form wizard tools
'dbbackup', # Backups - django-dbbackup
'allauth', # Base app for SSO
'allauth.account', # Extend user with accounts
@@ -607,6 +613,8 @@ if type(EXTRA_URL_SCHEMES) not in [list]: # pragma: no cover
# Internationalization
# https://docs.djangoproject.com/en/dev/topics/i18n/
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
LANGUAGES = [
@@ -679,6 +679,10 @@ main {
color: #A94442;
}
.form-error-message {
display: block;
}
.modal input {
width: 100%;
}
+90
View File
@@ -4,11 +4,14 @@ import json
import logging
import re
import warnings
from dataclasses import dataclass
from datetime import timedelta
from typing import Callable
from django.conf import settings
from django.core import mail as django_mail
from django.core.exceptions import AppRegistryNotReady
from django.core.management import call_command
from django.db.utils import OperationalError, ProgrammingError
from django.utils import timezone
@@ -125,6 +128,79 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **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():
"""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()
@scheduled_task(ScheduledTask.DAILY)
def delete_successful_tasks():
"""Delete successful task logs which are more than a month old."""
try:
@@ -167,6 +244,7 @@ def delete_successful_tasks():
results.delete()
@scheduled_task(ScheduledTask.DAILY)
def delete_old_error_logs():
"""Delete old error logs from the server."""
try:
@@ -189,6 +267,7 @@ def delete_old_error_logs():
return
@scheduled_task(ScheduledTask.DAILY)
def check_for_updates():
"""Check if there is an update for InvenTree."""
try:
@@ -231,6 +310,7 @@ def check_for_updates():
)
@scheduled_task(ScheduledTask.DAILY)
def update_exchange_rates():
"""Update currency exchange rates."""
try:
@@ -272,6 +352,16 @@ def update_exchange_rates():
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):
"""Send an email with the specified subject and body, to the specified recipients list."""
if type(recipients) == str:
+31 -26
View File
@@ -39,8 +39,9 @@ class ValidatorTest(TestCase):
"""Test part name validator."""
validate_part_name('hello world')
# Validate with some strange chars
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):
"""Test overage validator."""
@@ -309,7 +310,7 @@ class TestIncrement(TestCase):
def tests(self):
"""Test 'intelligent' incrementing function."""
tests = [
("", ""),
("", '1'),
(1, "2"),
("001", "002"),
("1001", "1002"),
@@ -418,7 +419,11 @@ class TestMPTT(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):
"""Test simple serial numbers."""
@@ -427,7 +432,7 @@ class TestSerialNumberExtraction(TestCase):
sn = e("1-5", 5, 1)
self.assertEqual(len(sn), 5, 1)
for i in range(1, 6):
self.assertIn(i, sn)
self.assertIn(str(i), sn)
sn = e("1, 2, 3, 4, 5", 5, 1)
self.assertEqual(len(sn), 5)
@@ -435,55 +440,55 @@ class TestSerialNumberExtraction(TestCase):
# Test partially specifying serials
sn = e("1, 2, 4+", 5, 1)
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
sn = e("1, 2, 3, AF5-69H, 5", 5, 1)
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
sn = e("1, 2, TG-4SR-92, 4+", 5, 1)
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
sn = e("1, A-2, 3+", 5, 1)
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
sn = e("1 2 ~ ~ ~", 5, 3)
sn = e("1 2 ~ ~ ~", 5, 2)
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)
self.assertIn(3, sn)
self.assertIn(13, sn)
self.assertIn('3', sn)
self.assertIn('13', sn)
sn = e("1+", 10, 1)
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)
self.assertEqual(len(sn), 4)
self.assertEqual(sn, [4, 1, 2, 3])
self.assertEqual(sn, ['4', '1', '2', '3'])
sn = e("~", 1, 1)
self.assertEqual(len(sn), 1)
self.assertEqual(sn, [1])
self.assertEqual(sn, ['2'])
sn = e("~", 1, 3)
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(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(sn, [5, 6, 7, 8])
self.assertEqual(sn, ['5', '6', '7', '8'])
def test_failures(self):
"""Test wron serial numbers."""
@@ -522,19 +527,19 @@ class TestSerialNumberExtraction(TestCase):
sn = e("1 3-5 9+2", 7, 1)
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)
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(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(sn, [14, 15])
self.assertEqual(sn, ['14', '15'])
class TestVersionNumber(TestCase):
+47 -17
View File
@@ -4,6 +4,7 @@ import re
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.core import validators
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _
@@ -37,17 +38,49 @@ def allowable_url_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):
"""Prevent some illegal characters in part names."""
for c in ['|', '#', '$', '{', '}']:
if c in str(value):
raise ValidationError(
_('Invalid character in part name')
)
"""Validate the name field for a Part instance
This function is exposed to any Validation plugins, and thus can be customized.
"""
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):
"""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')
if pattern:
@@ -59,28 +92,25 @@ def validate_part_ipn(value):
def validate_purchase_order_reference(value):
"""Validate the 'reference' field of a PurchaseOrder."""
pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX')
if pattern:
match = re.search(pattern, value)
from order.models import PurchaseOrder
if match is None:
raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern))
# If we get to here, run the "default" validation routine
PurchaseOrder.validate_reference_field(value)
def validate_sales_order_reference(value):
"""Validate the 'reference' field of a SalesOrder."""
pattern = common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_REGEX')
if pattern:
match = re.search(pattern, value)
from order.models import SalesOrder
if match is None:
raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern))
# If we get to here, run the "default" validation routine
SalesOrder.validate_reference_field(value)
def validate_tree_name(value):
"""Placeholder for legacy function used in migrations."""
...
def validate_overage(value):
+7 -4
View File
@@ -14,7 +14,6 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS
from InvenTree.serializers import UserSerializer
import InvenTree.helpers
from InvenTree.helpers import extract_serial_numbers
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus
@@ -260,7 +259,11 @@ class BuildOutputCreateSerializer(serializers.Serializer):
if serial_numbers:
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:
raise ValidationError({
'serial_numbers': e.messages,
@@ -270,12 +273,12 @@ class BuildOutputCreateSerializer(serializers.Serializer):
existing = []
for serial in self.serials:
if part.checkIfSerialNumberExists(serial):
if not part.validate_serial_number(serial):
existing.append(serial)
if len(existing) > 0:
msg = _("The following serial numbers already exist")
msg = _("The following serial numbers already exist or are invalid")
msg += " : "
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)
@InvenTree.tasks.scheduled_task(InvenTree.tasks.ScheduledTask.DAILY)
def check_overdue_build_orders():
"""Check if any outstanding BuildOrders have just become overdue
+1 -1
View File
@@ -389,7 +389,7 @@ class BuildTest(BuildAPITest):
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
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):
"""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
# If we get to here, run the "default" validation routine
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': {
'name': _('Barcode Support'),
'description': _('Enable barcode scanner support'),
@@ -1132,6 +1139,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'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': {
'name': _('Batch Code Template'),
'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 InvenTree.ready import isImportingData
from plugin import registry
from plugin.models import NotificationUserSetting
from plugin.models import NotificationUserSetting, PluginConfig
from users.models import Owner
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}'")
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):
"""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 InvenTree.tasks import ScheduledTask, scheduled_task
logger = logging.getLogger('inventree')
@scheduled_task(ScheduledTask.DAILY)
def delete_old_notifications():
"""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.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.api_tester import InvenTreeAPITestCase, PluginMixin
from InvenTree.helpers import InvenTreeTestCase, str2bool
from plugin import registry
from plugin.models import NotificationUserSetting, PluginConfig
from plugin.models import NotificationUserSetting
from .api import WebhookView
from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting,
@@ -540,7 +540,7 @@ class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
self.assertEqual(str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): True')
class PluginSettingsApiTest(InvenTreeAPITestCase):
class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
"""Tests for the plugin settings API."""
def test_plugin_list(self):
@@ -561,11 +561,8 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
def test_valid_plugin_slug(self):
"""Test that an valid plugin slug runs through."""
# load plugin configs
fixtures = PluginConfig.objects.all()
if not fixtures:
registry.reload_plugins()
fixtures = PluginConfig.objects.all()
# Activate plugin
registry.set_plugin_state('sample', True)
# get data
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
# Documentation: https://inventree.readthedocs.io/en/latest/start/config/
@@ -22,6 +26,13 @@ database:
# HOST: Database host address (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 ---
#ENGINE: mysql
@@ -105,8 +116,8 @@ sentry_enabled: False
# Set this variable to True to enable InvenTree Plugins
# Alternatively, use the environment variable INVENTREE_PLUGINS_ENABLED
plugins_enabled: False
#plugin_file: /path/to/plugins.txt
#plugin_dir: /path/to/plugins/
#plugin_file: '/path/to/plugins.txt'
#plugin_dir: '/path/to/plugins/'
# Allowed hosts (see ALLOWED_HOSTS in Django settings documentation)
# 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: '/home/inventree/data/static'
# BACKUP_DIR is the local filesystem location for storing backups
#backup_dir: '/home/inventree/data/backup'
# Background worker options
background:
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:
try:
# 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:
raise ValidationError({
'serial_numbers': e.messages,
@@ -1256,7 +1260,11 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
part = line_item.part
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:
raise ValidationError({
'serial_numbers': e.messages,
+3 -1
View File
@@ -6,9 +6,9 @@ from django.utils.translation import gettext_lazy as _
import common.notifications
import InvenTree.helpers
import InvenTree.tasks
import order.models
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from InvenTree.tasks import ScheduledTask, scheduled_task
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():
"""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():
"""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,
ListCreateDestroyAPIView)
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import (DownloadFile, increment, isNull, str2bool,
str2int)
from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull,
str2bool, str2int)
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
UpdateAPI)
@@ -717,16 +717,16 @@ class PartSerialNumberDetail(RetrieveAPI):
part = self.get_object()
# Calculate the "latest" serial number
latest = part.getLatestSerialNumber()
latest = part.get_latest_serial_number()
data = {
'latest': latest,
}
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
return Response(data)
+96 -82
View File
@@ -529,112 +529,126 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
return result
def checkIfSerialNumberExists(self, sn, exclude_self=False):
"""Check if a serial number exists for this Part.
def validate_serial_number(self, serial: str, stock_item=None, check_duplicates=True, raise_error=False):
"""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)
stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn)
stock = StockItem.objects.filter(part__in=parts, serial=serial)
if exclude_self:
stock = stock.exclude(pk=self.pk)
if stock_item:
# 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."""
conflicts = []
for serial in serials:
if self.checkIfSerialNumberExists(serial, exclude_self=True):
if not self.validate_serial_number(serial):
conflicts.append(serial)
return conflicts
def getLatestSerialNumber(self):
"""Return the "latest" serial number for this Part.
def get_latest_serial_number(self):
"""Find the 'latest' serial number for this Part.
If *all* the serial numbers are integers, then this will return the highest one.
Otherwise, it will simply return the serial number most recently added.
Here we attempt to find the "highest" serial number which exists for this Part.
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",
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():
return None
# Attempt to coerce the returned serial numbers to integers
# If *any* are not integers, fail!
try:
ordered = sorted(stock.all(), reverse=True, key=lambda n: int(n.serial))
# Sort in descending order
stock = stock.order_by('-serial_int', '-serial', '-pk')
if len(ordered) > 0:
return ordered[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)
# Return the first serial value
return stock[0].serial
@property
def full_name(self):
+2 -20
View File
@@ -277,7 +277,7 @@
</div>
{% 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" %}
</button>
{% endif %}
@@ -286,12 +286,6 @@
</div>
<div class='panel-content'>
{% 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>
@@ -618,19 +612,7 @@
});
$("[id^=bom-item-new]").click(function () {
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',
addBomItem({{ part.pk }}, {
onSuccess: function() {
$('#bom-table').bootstrapTable('refresh');
}
+4 -2
View File
@@ -323,12 +323,13 @@
{% endif %}
</td>
</tr>
{% if part.trackable and part.getLatestSerialNumber %}
{% with part.get_latest_serial_number as sn %}
{% if part.trackable and sn %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Latest Serial Number" %}</td>
<td>
{{ part.getLatestSerialNumber }}
{{ sn }}
<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" %}'>
<span class='fas fa-search'></span>
@@ -337,6 +338,7 @@
</td>
</tr>
{% endif %}
{% endwith %}
{% if part.default_location %}
<tr>
<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.locate.api import LocatePluginView
from plugin.models import PluginConfig, PluginSetting
from plugin.plugin import InvenTreePlugin
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):
"""Detail endpoint for a plugin-specific setting.
@@ -164,18 +197,10 @@ class PluginSettingDetail(RetrieveUpdateAPI):
plugin_slug = self.kwargs['plugin']
key = self.kwargs['key']
# 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")
# Look up plugin
plugin = check_plugin(plugin_slug)
# 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
settings = getattr(plugin, 'SETTINGS', {})
settings = getattr(plugin, 'settings', {})
if key not in settings:
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 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.views import APIView
@@ -13,6 +13,7 @@ from InvenTree.helpers import hash_barcode
from plugin import registry
from plugin.builtin.barcodes.inventree_barcode import (
InvenTreeExternalBarcodePlugin, InvenTreeInternalBarcodePlugin)
from users.models import RuleSet
class BarcodeScan(APIView):
@@ -139,6 +140,17 @@ class BarcodeAssign(APIView):
try:
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(
barcode_data=barcode_data,
barcode_hash=barcode_hash,
@@ -210,6 +222,17 @@ class BarcodeUnassign(APIView):
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
instance.unassign_barcode()
@@ -190,6 +190,8 @@ class BarcodeAPITest(InvenTreeAPITestCase):
"""Test that a barcode can be associated with a StockItem."""
item = StockItem.objects.get(pk=522)
self.assignRole('stock.change')
self.assertEqual(len(item.barcode_hash), 0)
barcode_data = 'A-TEST-BARCODE-STRING'
+133
View File
@@ -214,6 +214,139 @@ class ScheduleMixin:
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:
"""Mixin that enables custom URLs for the plugin."""
@@ -125,6 +125,19 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
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
response = self.assign(
{
@@ -188,6 +201,8 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
barcode = 'xyz-123'
self.assignRole('part.change')
# Test that an initial scan yields no results
response = self.scan(
{
@@ -196,6 +211,8 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
expected_code=400
)
self.assignRole('part.change')
# Attempt to assign to an invalid part ID
response = self.assign(
{
@@ -247,6 +264,8 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
self.assertIn('Barcode matches existing item', str(response.data['error']))
self.assignRole('part.change')
# Now test that we can unassign the barcode data also
response = self.unassign(
{
@@ -265,6 +284,17 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
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
response = self.assign(
data={
+3 -1
View File
@@ -8,7 +8,8 @@ from ..base.barcodes.mixins import BarcodeMixin
from ..base.event.mixins import EventMixin
from ..base.integration.mixins import (APICallMixin, AppMixin, NavigationMixin,
PanelMixin, ScheduleMixin,
SettingsMixin, UrlsMixin)
SettingsMixin, UrlsMixin,
ValidationMixin)
from ..base.label.mixins import LabelPrintingMixin
from ..base.locate.mixins import LocateMixin
@@ -25,6 +26,7 @@ __all__ = [
'ActionMixin',
'BarcodeMixin',
'LocateMixin',
'ValidationMixin',
'SingleNotificationMethod',
'BulkNotificationMethod',
]
+31 -3
View File
@@ -19,6 +19,7 @@ from django.contrib import admin
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from django.urls import clear_url_caches, include, re_path
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,
set_maintenance_mode)
@@ -67,6 +68,21 @@ class PluginsRegistry:
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):
"""Call a member function (named by 'func') of the plugin named by 'slug'.
@@ -349,6 +365,8 @@ class PluginsRegistry:
Raises:
error: IntegrationPluginError
"""
# Imports need to be in this level to prevent early db model imports
from InvenTree import version
from plugin.models import PluginConfig
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!
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:
# Exception if the database has not been migrated yet - check if test are running - raise if not
if not settings.PLUGIN_TESTING:
@@ -380,6 +398,7 @@ class PluginsRegistry:
plg_db = None
except (IntegrityError) as error: # pragma: no cover
logger.error(f"Error initializing plugin `{plg_name}`: {error}")
handle_error(error, log_name='init')
# Append reference to plugin
plg.db = plg_db
@@ -406,7 +425,16 @@ class PluginsRegistry:
# Run version check for plugin
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)
_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:
safe_reference(plugin=plg_i, key=plg_key)
else: # pragma: no cover
@@ -467,7 +495,7 @@ class PluginsRegistry:
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'):
config = plugin.plugin_config()
@@ -522,7 +550,7 @@ class PluginsRegistry:
apps_changed = False
# add them to the INSTALLED_APPS
for _, plugin in plugins:
for _key, plugin in plugins:
if plugin.mixin_enabled('app'):
plugin_path = self._get_plugin_path(plugin)
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 InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.api_tester import InvenTreeAPITestCase, PluginMixin
class PluginDetailAPITest(InvenTreeAPITestCase):
class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
"""Tests the plugin API endpoints."""
roles = [
@@ -72,26 +72,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
def test_admin_action(self):
"""Test the PluginConfig action commands."""
from plugin import registry
from plugin.models import PluginConfig
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
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]
test_plg = self.plugin_confs.first()
# deactivate plugin
response = self.client.post(url, {
'action': 'plugin_deactivate',
'index': 0,
'_selected_action': [f.pk for f in fixtures],
'_selected_action': [test_plg.pk],
}, follow=True)
self.assertEqual(response.status_code, 200)
@@ -99,7 +87,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
response = self.client.post(url, {
'action': 'plugin_deactivate',
'index': 0,
'_selected_action': [f.pk for f in fixtures],
'_selected_action': [test_plg.pk],
}, follow=True)
self.assertEqual(response.status_code, 200)
@@ -107,47 +95,27 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
response = self.client.post(url, {
'action': 'plugin_activate',
'index': 0,
'_selected_action': [f.pk for f in fixtures],
'_selected_action': [test_plg.pk],
}, follow=True)
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
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',
}, follow=True)
self.assertEqual(response.status_code, 200)
def test_model(self):
"""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
plg = fixtures.first()
plg = self.plugin_confs.first()
mixin_dict = plg.mixins()
self.assertIn('base', mixin_dict)
self.assertDictContainsSubset({'base': {'key': 'base', 'human_name': 'base'}}, mixin_dict)
# check reload on save
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.save()
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!
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!
existing = []
# Determine if any of the specified serial numbers are invalid
# Note "invalid" means either they already exist, or do not pass custom rules
invalid = []
errors = []
for serial in serials:
if part.checkIfSerialNumberExists(serial):
existing.append(serial)
try:
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 += ",".join([str(e) for e in existing])
msg += ",".join([str(e) for e in invalid])
raise ValidationError({
'serial_numbers': [msg],
'serial_numbers': errors + [msg]
})
except DjangoValidationError as e:
+12
View File
@@ -96,6 +96,7 @@
location: 7
quantity: 1
serial: 1000
serial_int: 1000
level: 0
tree_id: 0
lft: 0
@@ -121,6 +122,7 @@
location: 7
quantity: 1
serial: 1
serial_int: 1
level: 0
tree_id: 0
lft: 0
@@ -133,6 +135,7 @@
location: 7
quantity: 1
serial: 2
serial_int: 2
level: 0
tree_id: 0
lft: 0
@@ -145,6 +148,7 @@
location: 7
quantity: 1
serial: 3
serial_int: 3
level: 0
tree_id: 0
lft: 0
@@ -157,6 +161,7 @@
location: 7
quantity: 1
serial: 4
serial_int: 4
level: 0
tree_id: 0
lft: 0
@@ -169,6 +174,7 @@
location: 7
quantity: 1
serial: 5
serial_int: 5
level: 0
tree_id: 0
lft: 0
@@ -181,6 +187,7 @@
location: 7
quantity: 1
serial: 10
serial_int: 10
level: 0
tree_id: 0
lft: 0
@@ -193,6 +200,7 @@
location: 7
quantity: 1
serial: 11
serial_int: 11
level: 0
tree_id: 0
lft: 0
@@ -205,6 +213,7 @@
location: 7
quantity: 1
serial: 12
serial_int: 12
level: 0
tree_id: 0
lft: 0
@@ -217,6 +226,7 @@
location: 7
quantity: 1
serial: 20
serial_int: 20
level: 0
tree_id: 0
lft: 0
@@ -231,6 +241,7 @@
location: 7
quantity: 1
serial: 21
serial_int: 21
level: 0
tree_id: 0
lft: 0
@@ -245,6 +256,7 @@
location: 7
quantity: 1
serial: 22
serial_int: 22
level: 0
tree_id: 0
lft: 0
+70 -14
View File
@@ -180,9 +180,24 @@ class StockItemManager(TreeManager):
def generate_batch_code():
"""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.
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', '')
now = datetime.now()
@@ -260,15 +275,38 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
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
serial_int = 0
if serial is not None:
serial = str(serial).strip()
if serial not in [None, '']:
serial_int = extract_int(serial)
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 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
if self.pk is not None:
stock = stock.exclude(pk=self.pk)
self.serial = str(self.serial).strip()
if stock.exists():
raise ValidationError({"serial": _("StockItem with this serial number already exists")})
try:
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):
"""Validate the StockItem object (separate to field validation).
@@ -438,6 +492,8 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
if type(self.batch) is str:
self.batch = self.batch.strip()
self.validate_batch_code()
try:
# Trackable parts must have integer values for quantity field!
if self.part.trackable:
+6 -2
View File
@@ -342,7 +342,11 @@ class SerializeStockItemSerializer(serializers.Serializer):
serial_numbers = data['serial_numbers']
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:
raise ValidationError({
'serial_numbers': e.messages,
@@ -371,7 +375,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
serials = InvenTree.helpers.extract_serial_numbers(
data['serial_numbers'],
data['quantity'],
item.part.getLatestSerialNumberInt()
item.part.get_latest_serial_number()
)
item.serializeStock(
+1 -1
View File
@@ -495,7 +495,7 @@ class StockItemTest(StockAPITestCase):
# Check that each serial number was created
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
+56 -20
View File
@@ -4,8 +4,10 @@ import datetime
from django.core.exceptions import ValidationError
from django.db.models import Sum
from django.test import override_settings
from build.models import Build
from common.models import InvenTreeSetting
from InvenTree.helpers import InvenTreeTestCase
from InvenTree.status_codes import StockHistoryCode
from part.models import Part
@@ -140,7 +142,7 @@ class StockTest(StockTestBase):
item.save()
item.full_clean()
# Check that valid URLs pass
# Check that valid URLs pass - and check custon schemes
for good_url in [
'https://test.com',
'https://digikey.com/datasheets?file=1010101010101.bin',
@@ -163,6 +165,47 @@ class StockTest(StockTestBase):
item.link = long_url
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):
"""Test expiry date functionality for StockItem model."""
today = datetime.datetime.now().date()
@@ -848,22 +891,21 @@ class VariantTest(StockTestBase):
def test_serial_numbers(self):
"""Test serial number functionality for variant / template parts."""
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, self.user)
chair = Part.objects.get(pk=10000)
# Operations on the top-level object
self.assertTrue(chair.checkIfSerialNumberExists(1))
self.assertTrue(chair.checkIfSerialNumberExists(2))
self.assertTrue(chair.checkIfSerialNumberExists(3))
self.assertTrue(chair.checkIfSerialNumberExists(4))
self.assertTrue(chair.checkIfSerialNumberExists(5))
[self.assertFalse(chair.validate_serial_number(i)) for i in [1, 2, 3, 4, 5, 20, 21, 22]]
self.assertTrue(chair.checkIfSerialNumberExists(20))
self.assertTrue(chair.checkIfSerialNumberExists(21))
self.assertTrue(chair.checkIfSerialNumberExists(22))
self.assertFalse(chair.validate_serial_number(20))
self.assertFalse(chair.validate_serial_number(21))
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
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
variant = Part.objects.get(pk=10003)
self.assertEqual(variant.getLatestSerialNumber(), '22')
self.assertEqual(variant.get_latest_serial_number(), '22')
# Create a new serial number
n = variant.getLatestSerialNumber()
n = variant.get_latest_serial_number()
item = StockItem(
part=variant,
@@ -889,12 +931,6 @@ class VariantTest(StockTestBase):
with self.assertRaises(ValidationError):
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.
item.serial = int(n) + 1
item.save()
@@ -906,7 +942,7 @@ class VariantTest(StockTestBase):
with self.assertRaises(ValidationError):
item.save()
item.serial += 1
item.serial = int(n) + 2
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_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_BACKUP_ENABLE" icon="fa-hdd" %}
</tbody>
</table>
@@ -11,6 +11,7 @@
<table class='table table-striped table-condensed'>
<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_ENABLE_EXPIRY" icon="fa-stopwatch" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
+41
View File
@@ -15,6 +15,7 @@
*/
/* exported
addBomItem,
constructBomUploadTable,
deleteBomItems,
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.
* This data is used to import a BOM interactively.
*/
@@ -1171,6 +1196,13 @@ function loadBomTable(table, options={}) {
`/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
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
table.on('click', '.bom-delete-button', function() {
@@ -1231,6 +1231,8 @@ function loadBuildOutputTable(build_info, options={}) {
text += ` <small>({% trans "Batch" %}: ${row.batch})</small>`;
}
text += stockStatusDisplay(row.status, {classes: 'float-right'});
return renderLink(text, url);
},
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
var target = `${field_name}_${sub_field_name}_${nest_id}`;
for (var ii = errors.length-1; ii >= 0; ii--) {
var error_text = errors[ii];
addFieldErrorMessage(target, error_text, ii, options);
}
addFieldErrorMessage(target, errors, options);
}
}
}
@@ -1312,13 +1307,7 @@ function handleFormErrors(errors, fields={}, options={}) {
first_error_field = field_name;
}
// Add an entry for each returned error message
for (var ii = field_errors.length-1; ii >= 0; ii--) {
var error_text = field_errors[ii];
addFieldErrorMessage(field_name, error_text, ii, options);
}
addFieldErrorMessage(field_name, field_errors, options);
}
}
@@ -1341,6 +1330,16 @@ function handleFormErrors(errors, fields={}, 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);
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>
</h4></div>
Single line install:
```bash
curl https://raw.githubusercontent.com/InvenTree/InvenTree/master/contrib/install.sh | sh
```
<!-- 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
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
if test -f "$INVENTREE_CONFIG_FILE"; then
echo "$INVENTREE_CONFIG_FILE exists - skipping"
+21 -21
View File
@@ -14,9 +14,9 @@ build==0.8.0 \
--hash=sha256:19b0ed489f92ace6947698c3ca8436cb0556a66e2aa2d34cd70e2a5d27cd0437 \
--hash=sha256:887a6d471c901b1a6e6574ebaeeebb45e5269a79d095fe9a8f88d6614ed2e5f0
# via pip-tools
certifi==2022.6.15 \
--hash=sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d \
--hash=sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412
certifi==2022.9.24 \
--hash=sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14 \
--hash=sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382
# via
# -c requirements.txt
# requests
@@ -24,9 +24,9 @@ cfgv==3.3.1 \
--hash=sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426 \
--hash=sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736
# via pre-commit
charset-normalizer==2.1.0 \
--hash=sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5 \
--hash=sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413
charset-normalizer==2.1.1 \
--hash=sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845 \
--hash=sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f
# via
# -c requirements.txt
# requests
@@ -98,9 +98,9 @@ distlib==0.3.5 \
--hash=sha256:a7f75737c70be3b25e2bee06288cec4e4c221de18455b2dd037fe2a795cab2fe \
--hash=sha256:b710088c59f06338ca514800ad795a132da19fda270e3ce4affc74abf955a26c
# via virtualenv
django==3.2.15 \
--hash=sha256:115baf5049d5cf4163e43492cdc7139c306ed6d451e7d3571fe9612903903713 \
--hash=sha256:f71934b1a822f14a86c9ac9634053689279cd04ae69cb6ade4a59471b886582b
django==3.2.16 \
--hash=sha256:18ba8efa36b69cfcd4b670d0fa187c6fe7506596f0ababe580e16909bcdec121 \
--hash=sha256:3adc285124244724a394fa9b9839cc8cd116faf7d159554c43ecdaa8cdf0b94d
# via
# -c requirements.txt
# django-debug-toolbar
@@ -134,9 +134,9 @@ identify==2.5.3 \
--hash=sha256:25851c8c1370effb22aaa3c987b30449e9ff0cece408f810ae6ce408fdd20893 \
--hash=sha256:887e7b91a1be152b0d46bbf072130235a8117392b9f1828446079a816a05ef44
# via pre-commit
idna==3.3 \
--hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \
--hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d
idna==3.4 \
--hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \
--hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2
# via
# -c requirements.txt
# requests
@@ -192,9 +192,9 @@ pyparsing==3.0.9 \
--hash=sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb \
--hash=sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc
# via packaging
pytz==2022.1 \
--hash=sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7 \
--hash=sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c
pytz==2022.4 \
--hash=sha256:2c0784747071402c6e99f0bafdb7da0fa22645f06554c7ae06bf6358897e9c91 \
--hash=sha256:48ce799d83b6f8aab2020e369b627446696619e79645419610b9facd909b3174
# via
# -c requirements.txt
# django
@@ -245,9 +245,9 @@ snowballstemmer==2.2.0 \
--hash=sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1 \
--hash=sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a
# via pydocstyle
sqlparse==0.4.2 \
--hash=sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae \
--hash=sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d
sqlparse==0.4.3 \
--hash=sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34 \
--hash=sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268
# via
# -c requirements.txt
# django
@@ -266,9 +266,9 @@ typing-extensions==4.3.0 \
--hash=sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02 \
--hash=sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6
# via django-test-migrations
urllib3==1.26.11 \
--hash=sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc \
--hash=sha256:ea6e8fb210b19d950fab93b60c9009226c63a28808bc8386e05301e25883ac0a
urllib3==1.26.12 \
--hash=sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e \
--hash=sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997
# via
# -c requirements.txt
# requests
+1
View File
@@ -7,6 +7,7 @@ django-allauth-2fa # MFA / 2FA
django-cleanup # Automated deletion of old / unused uploaded files
django-cors-headers # CORS headers extension for DRF
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-filter # Extended filtering options
django-formtools # Form wizard tools
+28 -22
View File
@@ -4,7 +4,7 @@
#
# pip-compile --output-file=requirements.txt requirements.in
#
arrow==1.2.2
arrow==1.2.3
# via django-q
asgiref==3.5.2
# via django
@@ -16,7 +16,7 @@ blessed==1.19.1
# via django-q
brotli==1.0.9
# via fonttools
certifi==2022.6.15
certifi==2022.9.24
# via
# requests
# sentry-sdk
@@ -24,7 +24,7 @@ cffi==1.15.1
# via
# cryptography
# weasyprint
charset-normalizer==2.1.0
charset-normalizer==2.1.1
# via requests
coreapi==2.3.3
# via -r requirements.in
@@ -34,7 +34,7 @@ cryptography==3.4.8
# via
# -r requirements.in
# pyjwt
cssselect2==0.6.0
cssselect2==0.7.0
# via weasyprint
defusedxml==0.7.1
# via
@@ -42,12 +42,13 @@ defusedxml==0.7.1
# python3-openid
diff-match-patch==20200713
# via django-import-export
django==3.2.15
django==3.2.16
# via
# -r requirements.in
# django-allauth
# django-allauth-2fa
# django-cors-headers
# django-dbbackup
# django-error-report
# django-filter
# django-formtools
@@ -79,11 +80,13 @@ django-cors-headers==3.13.0
# via -r requirements.in
django-crispy-forms==1.14.0
# via -r requirements.in
django-dbbackup==4.0.2
# via -r requirements.in
django-error-report==0.2.0
# via -r requirements.in
django-filter==22.1
# via -r requirements.in
django-formtools==2.3
django-formtools==2.4
# via -r requirements.in
django-import-export==2.5.0
# via -r requirements.in
@@ -113,23 +116,23 @@ django-stdimage==5.3.0
# via -r requirements.in
django-user-sessions==1.7.1
# via -r requirements.in
django-weasyprint==2.1.0
django-weasyprint==2.2.0
# via -r requirements.in
django-xforwardedfor-middleware==2.0
# via -r requirements.in
djangorestframework==3.13.1
djangorestframework==3.14.0
# via -r requirements.in
et-xmlfile==1.1.0
# via openpyxl
fonttools[woff]==4.34.4
fonttools[woff]==4.37.4
# via weasyprint
gunicorn==20.1.0
# via -r requirements.in
html5lib==1.1
# via weasyprint
idna==3.3
idna==3.4
# via requests
importlib-metadata==4.12.0
importlib-metadata==5.0.0
# via markdown
itypes==1.2.0
# via coreapi
@@ -141,7 +144,7 @@ markuppy==1.14
# via tablib
markupsafe==2.1.1
# via jinja2
oauthlib==3.2.0
oauthlib==3.2.1
# via requests-oauthlib
odfpy==1.4.1
# via tablib
@@ -163,24 +166,25 @@ py-moneyed==1.2
# django-money
pycparser==2.21
# via cffi
pydyf==0.2.0
pydyf==0.5.0
# via weasyprint
pyjwt[crypto]==2.4.0
pyjwt[crypto]==2.5.0
# via django-allauth
pyphen==0.12.0
pyphen==0.13.0
# via weasyprint
python-barcode[images]==0.14.0
# via -r requirements.in
python-dateutil==2.8.2
# via arrow
python-fsutil==0.6.1
python-fsutil==0.7.0
# via django-maintenance-mode
python3-openid==3.2.0
# via django-allauth
pytz==2022.1
pytz==2022.4
# via
# babel
# django
# django-dbbackup
# djangorestframework
pyyaml==6.0
# via tablib
@@ -194,7 +198,7 @@ redis==3.5.3
# via
# django-q
# django-redis
regex==2022.8.17
regex==2022.9.13
# via -r requirements.in
requests==2.28.1
# via
@@ -203,7 +207,7 @@ requests==2.28.1
# requests-oauthlib
requests-oauthlib==1.3.1
# via django-allauth
sentry-sdk==1.9.0
sentry-sdk==1.9.10
# via -r requirements.in
six==1.16.0
# via
@@ -211,7 +215,7 @@ six==1.16.0
# blessed
# html5lib
# python-dateutil
sqlparse==0.4.2
sqlparse==0.4.3
# via
# django
# django-sql-utils
@@ -224,9 +228,11 @@ tinycss2==1.1.1
# bleach
# cssselect2
# weasyprint
types-cryptography==3.3.23
# via pyjwt
uritemplate==4.1.1
# via coreapi
urllib3==1.26.11
urllib3==1.26.12
# via
# requests
# sentry-sdk
@@ -246,7 +252,7 @@ xlrd==2.0.1
# via tablib
xlwt==1.3.0
# via tablib
zipp==3.8.1
zipp==3.9.0
# via importlib-metadata
zopfli==0.2.1
# 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")
@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):
"""Performs database migrations.