mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-13 20:10:49 +00:00
Merge branch 'master' into generic-parameters
This commit is contained in:
@@ -57,7 +57,7 @@ runs:
|
|||||||
run: |
|
run: |
|
||||||
python3 -m pip install -U pip
|
python3 -m pip install -U pip
|
||||||
pip3 install -U invoke wheel
|
pip3 install -U invoke wheel
|
||||||
pip3 install 'uv>=0.9.4'
|
pip3 install 'uv>=0.9.6'
|
||||||
- name: Allow uv to use the system Python by default
|
- name: Allow uv to use the system Python by default
|
||||||
run: echo "UV_SYSTEM_PYTHON=1" >> $GITHUB_ENV
|
run: echo "UV_SYSTEM_PYTHON=1" >> $GITHUB_ENV
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -705,7 +705,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Upload SARIF file
|
- name: Upload SARIF file
|
||||||
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # pin@v3
|
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # pin@v3
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
category: zizmor
|
category: zizmor
|
||||||
|
|||||||
@@ -67,6 +67,6 @@ jobs:
|
|||||||
|
|
||||||
# Upload the results to GitHub's code scanning dashboard.
|
# Upload the results to GitHub's code scanning dashboard.
|
||||||
- name: "Upload to code-scanning"
|
- name: "Upload to code-scanning"
|
||||||
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ name: inventree
|
|||||||
description: Open Source Inventory Management System
|
description: Open Source Inventory Management System
|
||||||
homepage: https://inventree.org
|
homepage: https://inventree.org
|
||||||
notifications: true
|
notifications: true
|
||||||
buildpack: https://github.com/mjmair/heroku-buildpack-python#v216-mjmair
|
buildpack: https://github.com/matmair/null-buildpack#master
|
||||||
env:
|
env:
|
||||||
- STACK=heroku-20
|
|
||||||
- DISABLE_COLLECTSTATIC=1
|
|
||||||
- INVENTREE_DB_ENGINE=sqlite3
|
- INVENTREE_DB_ENGINE=sqlite3
|
||||||
- INVENTREE_DB_NAME=database.sqlite3
|
- INVENTREE_DB_NAME=database.sqlite3
|
||||||
- INVENTREE_PLUGINS_ENABLED
|
- INVENTREE_PLUGINS_ENABLED
|
||||||
@@ -38,4 +36,7 @@ dependencies:
|
|||||||
- "libffi7 | libffi8"
|
- "libffi7 | libffi8"
|
||||||
targets:
|
targets:
|
||||||
ubuntu-20.04: true
|
ubuntu-20.04: true
|
||||||
|
ubuntu-22.04: true
|
||||||
|
ubuntu-24.04: true
|
||||||
debian-11: true
|
debian-11: true
|
||||||
|
debian-12: true
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3.9.2
|
|
||||||
+16
-4
@@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file (starting wi
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased] - yyyy-mm-dd (in UTC)
|
## Unreleased - YYYY-MM-DD
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Adds "Category" columns to BOM and Build Item tables and APIs in [#10722](https://github.com/inventree/InvenTree/pull/10772)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
## 1.1.0 - 2025-11-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -20,16 +30,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Adds UI elements to "check" and "uncheck" sales order shipments in [#10654](https://github.com/inventree/InvenTree/pull/10654)
|
- Adds UI elements to "check" and "uncheck" sales order shipments in [#10654](https://github.com/inventree/InvenTree/pull/10654)
|
||||||
- Allow assigning project codes to order line items in [#10657](https://github.com/inventree/InvenTree/pull/10657)
|
- Allow assigning project codes to order line items in [#10657](https://github.com/inventree/InvenTree/pull/10657)
|
||||||
- Added support for webauthn login for the frontend in [#9729](https://github.com/inventree/InvenTree/pull/9729)
|
- Added support for webauthn login for the frontend in [#9729](https://github.com/inventree/InvenTree/pull/9729)
|
||||||
|
- Added support for Debian 12, Ubuntu 22.04 and Ubuntu 24.04 in the installer and package in [#10705](https://github.com/inventree/InvenTree/pull/10705)
|
||||||
|
- Support for S3 and SFTP storage backends for media and static files ([#10140](https://github.com/inventree/InvenTree/pull/10140))
|
||||||
|
- Adds hooks for custom UI spotlight actions in [#10720](https://github.com/inventree/InvenTree/pull/10720)
|
||||||
|
- Support uploading attachments against SupplierPart in [#10724](https://github.com/inventree/InvenTree/pull/10724)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Changed site URL check to allow protocol mismatches if `INVENTREE_SITE_LAX_PROTOCOL` is set to `True` (default) in [#10454](https://github.com/inventree/InvenTree/pull/10454)
|
- Changed site URL check to allow protocol mismatches if `INVENTREE_SITE_LAX_PROTOCOL` is set to `True` (default) in [#10454](https://github.com/inventree/InvenTree/pull/10454)
|
||||||
- Changed call signature of `get_global_setting` to use `environment_key` instead of `enviroment_key` in [#10557](https://github.com/inventree/InvenTree/pull/10557)
|
- Changed call signature of `get_global_setting` to use `environment_key` instead of `enviroment_key` in [#10557](https://github.com/inventree/InvenTree/pull/10557)
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
|
## 1.0.0 - 2025-09-15
|
||||||
## [1.0.0 ] - 2025-09-15
|
|
||||||
|
|
||||||
The first "stable" release following semver but not extensively other than the previous releases. The use of 1.0 indicates the stability that users already expect from InvenTree.
|
The first "stable" release following semver but not extensively other than the previous releases. The use of 1.0 indicates the stability that users already expect from InvenTree.
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ asgiref==3.10.0 \
|
|||||||
--hash=sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734 \
|
--hash=sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734 \
|
||||||
--hash=sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e
|
--hash=sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e
|
||||||
# via django
|
# via django
|
||||||
django==4.2.25 \
|
django==4.2.26 \
|
||||||
--hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
|
--hash=sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a \
|
||||||
--hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
|
--hash=sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280
|
||||||
# via
|
# via
|
||||||
# -r contrib/container/requirements.in
|
# -r contrib/container/requirements.in
|
||||||
# django-auth-ldap
|
# django-auth-ldap
|
||||||
@@ -229,26 +229,26 @@ typing-extensions==4.15.0 \
|
|||||||
# via
|
# via
|
||||||
# psycopg
|
# psycopg
|
||||||
# psycopg-pool
|
# psycopg-pool
|
||||||
uv==0.9.4 \
|
uv==0.9.7 \
|
||||||
--hash=sha256:03a85b02e6ccf1b705ce78bd98da78c90d5a0d0f941756ee842825d850cada2f \
|
--hash=sha256:0fdbfad5b367e7a3968264af6da5bbfffd4944a90319042f166e8df1a2d9de09 \
|
||||||
--hash=sha256:0840346084d28aa5345eeabcb7f9e727448b56b3b399300447a9155066909925 \
|
--hash=sha256:134e0daac56f9e399ccdfc9e4635bc0a13c234cad9224994c67bae462e07399a \
|
||||||
--hash=sha256:253133f7f2eac8fed10ad601c56ddcd13d8d81d9343ed9e95873d19b149199f2 \
|
--hash=sha256:1aaf79b4234400e9e2fbf5b50b091726ccbb0b6d4d032edd3dfd4c9673d89dca \
|
||||||
--hash=sha256:2feb2adc0a2eb41a757b9cef3226f649452423badf20d68d177b6649342d021d \
|
--hash=sha256:34fe0af83fcafb9e2b786f4bd633a06c878d548a7c479594ffb5607db8778471 \
|
||||||
--hash=sha256:39f6b459fdabc80c0afc080ba8bce86e048afa799bc6c5c372f78b14195cf49c \
|
--hash=sha256:555ee72146b8782c73d755e4a21c9885c6bfc81db0ffca2220d52dddae007eb7 \
|
||||||
--hash=sha256:3e1b5df83e96a8128b81a9f2bd72a4db752f691515914471b76df994339d2c35 \
|
--hash=sha256:56a440ccde7624a7bc070e1c2492b358c67aea9b8f17bc243ea27c5871c8d02c \
|
||||||
--hash=sha256:42012fcfdbaec08e1c009bbdbf96296b05e0e86feb83e1182d9335ae86a288d2 \
|
--hash=sha256:62b315f62669899076a1953fba6baf50bd2b57f66f656280491331dcedd7e6c6 \
|
||||||
--hash=sha256:57582a149de7788a83f998ddad2dfc50a328aae7a474fbb1617c73a9e2b42ebf \
|
--hash=sha256:635e82c2d0d8b001618af82e4f2724350f15814f6462a71b3ebd44adec21f03c \
|
||||||
--hash=sha256:610a219a6d92cc56c1a24888118a5ae1b07233b93dde0565d64fe198a2c7c376 \
|
--hash=sha256:7019f4416925f4091b9d28c1cf3e8444cf910c4ede76bdf1f6b9a56ca5f97985 \
|
||||||
--hash=sha256:787cf63c2f5c97cc6b30915632351eac655fcd4ec19620bc67cbd6855975817b \
|
--hash=sha256:777bb1de174319245a35e4f805d3b4484d006ebedae71d3546f95e7c28a5f436 \
|
||||||
--hash=sha256:79efd533016d9bf077056cac72e68fa501e9d0e09576a2c375f7c286d19be9d6 \
|
--hash=sha256:89697fa0d7384ba047daf75df844ee7800235105e41d08e0c876861a2b4aa90e \
|
||||||
--hash=sha256:9ee7695b6632b74ea62d67fcef732e519d1fdb3f9ecf81c99bfd5a354ff925fb \
|
--hash=sha256:8cf6bc2482d1293cc630f66b862b494c09acda9b7faff7307ef52667a2b3ad49 \
|
||||||
--hash=sha256:aa0e144df0276945cbe49e30b577cf51e19b808e5ca55e23b8a1a354857e1629 \
|
--hash=sha256:b5f1fb8203a77853db176000e8f30d5815ab175dc46199db059f97a72fc51110 \
|
||||||
--hash=sha256:b7f7d3fd51627fbcca06cf75d327e060db924d4ca054e1e934b71682d58f1f51 \
|
--hash=sha256:bb8bfcc2897f7653522abc2cae80233af756ad857bfbbbbe176f79460cbba417 \
|
||||||
--hash=sha256:c353be83686f769bf50e6c6bc8591ad59752b492c6bb51296e378e55521482f5 \
|
--hash=sha256:bcf878528bd079fe8ae15928b5dfa232fac8b0e1854a2102da6ae1a833c31276 \
|
||||||
--hash=sha256:d89f88df09d571f6d06228b32a6a71100905eb64343247317d363bcd774ee870 \
|
--hash=sha256:c9810ee8173dce129c49b338d5e97f3d7c7e9435f73e0b9b26c2f37743d3bb9e \
|
||||||
--hash=sha256:dcbcc963232e13e279002844e983cd6d0f53560e75d8a3f7a68e7d68a6021235 \
|
--hash=sha256:d13da6521d4e841b1e0a9fda82e793dcf8458a323a9e8955f50903479d0bfa97 \
|
||||||
--hash=sha256:df3288f85bd6bfb4b8722bb7223d6723de7c32d213596573d92803f89af9007c \
|
--hash=sha256:d6e5fe28ca05a4b576c0e8da5f69251dc187a67054829cfc4afb2bfa1767114b \
|
||||||
--hash=sha256:fa33399d5e3e31b753910cfaa6f87022736339cadb140c8896dccb7c6a855e32
|
--hash=sha256:edd768f6730bba06aa10fdbd80ee064569f7236806f636bf65b68136a430aad0
|
||||||
# via -r contrib/container/requirements.in
|
# via -r contrib/container/requirements.in
|
||||||
wheel==0.45.1 \
|
wheel==0.45.1 \
|
||||||
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
|
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
|
||||||
|
|||||||
+33
-71
@@ -1,9 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# This script was generated by bashly 1.1.1 (https://bashly.dannyb.co)
|
# This script was generated by bashly 1.3.3 (https://bashly.dev)
|
||||||
# Modifying it manually is not recommended
|
# Modifying it manually is not recommended
|
||||||
|
|
||||||
if [[ "${BASH_VERSINFO:-0}" -lt 4 ]]; then
|
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2))); then
|
||||||
printf "bash version 4 or higher is required\n" >&2
|
printf "bash version 4.2 or higher is required\n" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -56,17 +56,16 @@ root_command() {
|
|||||||
get_distribution
|
get_distribution
|
||||||
echo "### Detected distribution: $OS $VER"
|
echo "### Detected distribution: $OS $VER"
|
||||||
SUPPORTED=true # is this OS supported?
|
SUPPORTED=true # is this OS supported?
|
||||||
NEEDS_LIBSSL1_1=false # does this OS need libssl1.1?
|
|
||||||
|
|
||||||
DIST_OS=${OS,,}
|
DIST_OS=${OS,,}
|
||||||
DIST_VER=$VER
|
DIST_VER=$VER
|
||||||
|
|
||||||
case "$OS" in
|
case "$OS" in
|
||||||
Ubuntu)
|
Ubuntu)
|
||||||
if [[ $VER == "22.04" ]]; then
|
if [[ $VER == "24.04" ]]; then
|
||||||
|
SUPPORTED=true
|
||||||
|
elif [[ $VER == "22.04" ]]; then
|
||||||
SUPPORTED=true
|
SUPPORTED=true
|
||||||
NEEDS_LIBSSL1_1=true
|
|
||||||
DIST_VER="20.04"
|
|
||||||
elif [[ $VER == "20.04" ]]; then
|
elif [[ $VER == "20.04" ]]; then
|
||||||
SUPPORTED=true
|
SUPPORTED=true
|
||||||
else
|
else
|
||||||
@@ -75,7 +74,6 @@ root_command() {
|
|||||||
;;
|
;;
|
||||||
"Debian GNU/Linux" | "debian gnu/linux" | Raspbian)
|
"Debian GNU/Linux" | "debian gnu/linux" | Raspbian)
|
||||||
if [[ $VER == "12" ]]; then
|
if [[ $VER == "12" ]]; then
|
||||||
DIST_VER="11"
|
|
||||||
SUPPORTED=true
|
SUPPORTED=true
|
||||||
elif [[ $VER == "11" ]]; then
|
elif [[ $VER == "11" ]]; then
|
||||||
SUPPORTED=true
|
SUPPORTED=true
|
||||||
@@ -111,15 +109,6 @@ root_command() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ $NEEDS_LIBSSL1_1 == "true" ]]; then
|
|
||||||
echo "### Installing libssl1.1"
|
|
||||||
|
|
||||||
echo "deb http://security.ubuntu.com/ubuntu focal-security main" | sudo tee /etc/apt/sources.list.d/focal-security.list
|
|
||||||
do_call "sudo apt-get update"
|
|
||||||
do_call "sudo apt-get install libssl1.1"
|
|
||||||
sudo rm /etc/apt/sources.list.d/focal-security.list
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "### Getting and adding key"
|
echo "### Getting and adding key"
|
||||||
curl -fsSL https://dl.packager.io/srv/$publisher/InvenTree/key | gpg --dearmor | tee /etc/apt/trusted.gpg.d/pkgr-inventree.gpg > /dev/null
|
curl -fsSL https://dl.packager.io/srv/$publisher/InvenTree/key | gpg --dearmor | tee /etc/apt/trusted.gpg.d/pkgr-inventree.gpg > /dev/null
|
||||||
echo "### Adding package source"
|
echo "### Adding package source"
|
||||||
@@ -146,15 +135,7 @@ version_command() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install.sh_usage() {
|
install.sh_usage() {
|
||||||
if [[ -n $long_usage ]]; then
|
printf "install.sh - Interactive installer for InvenTree\n\n"
|
||||||
printf "install.sh - Interactive installer for InvenTree\n"
|
|
||||||
echo
|
|
||||||
|
|
||||||
else
|
|
||||||
printf "install.sh - Interactive installer for InvenTree\n"
|
|
||||||
echo
|
|
||||||
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf "%s\n" "Usage:"
|
printf "%s\n" "Usage:"
|
||||||
printf " install.sh [SOURCE] [PUBLISHER] [OPTIONS]\n"
|
printf " install.sh [SOURCE] [PUBLISHER] [OPTIONS]\n"
|
||||||
@@ -162,7 +143,7 @@ install.sh_usage() {
|
|||||||
printf " install.sh --version | -v\n"
|
printf " install.sh --version | -v\n"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
if [[ -n $long_usage ]]; then
|
if [[ -n "$long_usage" ]]; then
|
||||||
printf "%s\n" "Options:"
|
printf "%s\n" "Options:"
|
||||||
|
|
||||||
printf " %s\n" "--no-call, -n"
|
printf " %s\n" "--no-call, -n"
|
||||||
@@ -184,13 +165,13 @@ install.sh_usage() {
|
|||||||
|
|
||||||
printf " %s\n" "SOURCE"
|
printf " %s\n" "SOURCE"
|
||||||
printf " Package source that should be used\n"
|
printf " Package source that should be used\n"
|
||||||
printf " Allowed: stable, master, main\n"
|
printf " %s\n" "Allowed: stable, master, main"
|
||||||
printf " Default: stable\n"
|
printf " %s\n" "Default: stable"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
printf " %s\n" "PUBLISHER"
|
printf " %s\n" "PUBLISHER"
|
||||||
printf " Publisher that should be used\n"
|
printf " Publisher that should be used\n"
|
||||||
printf " Default: inventree\n"
|
printf " %s\n" "Default: inventree"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
printf "%s\n" "Examples:"
|
printf "%s\n" "Examples:"
|
||||||
@@ -203,11 +184,14 @@ install.sh_usage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalize_input() {
|
normalize_input() {
|
||||||
local arg flags
|
local arg passthru flags
|
||||||
|
passthru=false
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
arg="$1"
|
arg="$1"
|
||||||
if [[ $arg =~ ^(--[a-zA-Z0-9_\-]+)=(.+)$ ]]; then
|
if [[ $passthru == true ]]; then
|
||||||
|
input+=("$arg")
|
||||||
|
elif [[ $arg =~ ^(--[a-zA-Z0-9_\-]+)=(.+)$ ]]; then
|
||||||
input+=("${BASH_REMATCH[1]}")
|
input+=("${BASH_REMATCH[1]}")
|
||||||
input+=("${BASH_REMATCH[2]}")
|
input+=("${BASH_REMATCH[2]}")
|
||||||
elif [[ $arg =~ ^(-[a-zA-Z0-9])=(.+)$ ]]; then
|
elif [[ $arg =~ ^(-[a-zA-Z0-9])=(.+)$ ]]; then
|
||||||
@@ -218,6 +202,9 @@ normalize_input() {
|
|||||||
for ((i = 0; i < ${#flags}; i++)); do
|
for ((i = 0; i < ${#flags}; i++)); do
|
||||||
input+=("-${flags:i:1}")
|
input+=("-${flags:i:1}")
|
||||||
done
|
done
|
||||||
|
elif [[ "$arg" == "--" ]]; then
|
||||||
|
passthru=true
|
||||||
|
input+=("$arg")
|
||||||
else
|
else
|
||||||
input+=("$arg")
|
input+=("$arg")
|
||||||
fi
|
fi
|
||||||
@@ -226,37 +213,11 @@ normalize_input() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
inspect_args() {
|
|
||||||
if ((${#args[@]})); then
|
|
||||||
readarray -t sorted_keys < <(printf '%s\n' "${!args[@]}" | sort)
|
|
||||||
echo args:
|
|
||||||
for k in "${sorted_keys[@]}"; do echo "- \${args[$k]} = ${args[$k]}"; done
|
|
||||||
else
|
|
||||||
echo args: none
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ((${#other_args[@]})); then
|
|
||||||
echo
|
|
||||||
echo other_args:
|
|
||||||
echo "- \${other_args[*]} = ${other_args[*]}"
|
|
||||||
for i in "${!other_args[@]}"; do
|
|
||||||
echo "- \${other_args[$i]} = ${other_args[$i]}"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ((${#deps[@]})); then
|
|
||||||
readarray -t sorted_keys < <(printf '%s\n' "${!deps[@]}" | sort)
|
|
||||||
echo
|
|
||||||
echo deps:
|
|
||||||
for k in "${sorted_keys[@]}"; do echo "- \${deps[$k]} = ${deps[$k]}"; done
|
|
||||||
fi
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
parse_requirements() {
|
parse_requirements() {
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "${1:-}" in
|
key="$1"
|
||||||
|
case "$key" in
|
||||||
--version | -v)
|
--version | -v)
|
||||||
version_command
|
version_command
|
||||||
exit
|
exit
|
||||||
@@ -301,11 +262,10 @@ parse_requirements() {
|
|||||||
*)
|
*)
|
||||||
|
|
||||||
if [[ -z ${args['source']+x} ]]; then
|
if [[ -z ${args['source']+x} ]]; then
|
||||||
|
|
||||||
args['source']=$1
|
args['source']=$1
|
||||||
shift
|
shift
|
||||||
elif [[ -z ${args['publisher']+x} ]]; then
|
|
||||||
|
|
||||||
|
elif [[ -z ${args['publisher']+x} ]]; then
|
||||||
args['publisher']=$1
|
args['publisher']=$1
|
||||||
shift
|
shift
|
||||||
else
|
else
|
||||||
@@ -321,7 +281,7 @@ parse_requirements() {
|
|||||||
[[ -n ${args['source']:-} ]] || args['source']="stable"
|
[[ -n ${args['source']:-} ]] || args['source']="stable"
|
||||||
[[ -n ${args['publisher']:-} ]] || args['publisher']="inventree"
|
[[ -n ${args['publisher']:-} ]] || args['publisher']="inventree"
|
||||||
|
|
||||||
if [[ -n ${args['source']} ]] && [[ ! ${args['source']} =~ ^(stable|master|main)$ ]]; then
|
if [[ -n ${args['source']:-} ]] && [[ ! ${args['source']:-} =~ ^(stable|master|main)$ ]]; then
|
||||||
printf "%s\n" "source must be one of: stable, master, main" >&2
|
printf "%s\n" "source must be one of: stable, master, main" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -329,18 +289,19 @@ parse_requirements() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
version="2.0"
|
declare -g version="2.0"
|
||||||
long_usage=''
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
run() {
|
run() {
|
||||||
declare -A args=()
|
|
||||||
declare -A deps=()
|
declare -g long_usage=''
|
||||||
declare -a other_args=()
|
declare -g -A args=()
|
||||||
declare -a input=()
|
declare -g -A deps=()
|
||||||
|
declare -g -a env_var_names=()
|
||||||
|
declare -g -a input=()
|
||||||
|
|
||||||
normalize_input "$@"
|
normalize_input "$@"
|
||||||
parse_requirements "${input[@]}"
|
parse_requirements "${input[@]}"
|
||||||
|
|
||||||
@@ -349,5 +310,6 @@ run() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
command_line_args=("$@")
|
||||||
initialize
|
initialize
|
||||||
run "$@"
|
run "${command_line_args[@]}"
|
||||||
|
|||||||
@@ -46,17 +46,16 @@ echo "### Installer for InvenTree - source: $publisher/$source_url"
|
|||||||
get_distribution
|
get_distribution
|
||||||
echo "### Detected distribution: $OS $VER"
|
echo "### Detected distribution: $OS $VER"
|
||||||
SUPPORTED=true # is this OS supported?
|
SUPPORTED=true # is this OS supported?
|
||||||
NEEDS_LIBSSL1_1=false # does this OS need libssl1.1?
|
|
||||||
|
|
||||||
DIST_OS=${OS,,}
|
DIST_OS=${OS,,}
|
||||||
DIST_VER=$VER
|
DIST_VER=$VER
|
||||||
|
|
||||||
case "$OS" in
|
case "$OS" in
|
||||||
Ubuntu)
|
Ubuntu)
|
||||||
if [[ $VER == "22.04" ]]; then
|
if [[ $VER == "24.04" ]]; then
|
||||||
|
SUPPORTED=true
|
||||||
|
elif [[ $VER == "22.04" ]]; then
|
||||||
SUPPORTED=true
|
SUPPORTED=true
|
||||||
NEEDS_LIBSSL1_1=true
|
|
||||||
DIST_VER="20.04"
|
|
||||||
elif [[ $VER == "20.04" ]]; then
|
elif [[ $VER == "20.04" ]]; then
|
||||||
SUPPORTED=true
|
SUPPORTED=true
|
||||||
else
|
else
|
||||||
@@ -100,15 +99,6 @@ for pkg in $REQS; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ $NEEDS_LIBSSL1_1 == "true" ]]; then
|
|
||||||
echo "### Installing libssl1.1"
|
|
||||||
|
|
||||||
echo "deb http://security.ubuntu.com/ubuntu focal-security main" | sudo tee /etc/apt/sources.list.d/focal-security.list
|
|
||||||
do_call "sudo apt-get update"
|
|
||||||
do_call "sudo apt-get install libssl1.1"
|
|
||||||
sudo rm /etc/apt/sources.list.d/focal-security.list
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "### Getting and adding key"
|
echo "### Getting and adding key"
|
||||||
curl -fsSL https://dl.packager.io/srv/$publisher/InvenTree/key | gpg --dearmor | tee /etc/apt/trusted.gpg.d/pkgr-inventree.gpg > /dev/null
|
curl -fsSL https://dl.packager.io/srv/$publisher/InvenTree/key | gpg --dearmor | tee /etc/apt/trusted.gpg.d/pkgr-inventree.gpg > /dev/null
|
||||||
echo "### Adding package source"
|
echo "### Adding package source"
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ function detect_envs() {
|
|||||||
export INVENTREE_DB_HOST=${INVENTREE_DB_HOST:-samplehost}
|
export INVENTREE_DB_HOST=${INVENTREE_DB_HOST:-samplehost}
|
||||||
export INVENTREE_DB_PORT=${INVENTREE_DB_PORT:-123456}
|
export INVENTREE_DB_PORT=${INVENTREE_DB_PORT:-123456}
|
||||||
|
|
||||||
export INVENTREE_SITE_URL=${INVENTREE_SITE_URL}
|
export INVENTREE_SITE_URL=${INVENTREE_SITE_URL:-http://${INVENTREE_IP}}
|
||||||
|
|
||||||
export SETUP_CONF_LOADED=true
|
export SETUP_CONF_LOADED=true
|
||||||
fi
|
fi
|
||||||
@@ -327,7 +327,7 @@ function update_or_install() {
|
|||||||
|
|
||||||
# Run update as app user
|
# Run update as app user
|
||||||
echo "# POI12| Updating InvenTree"
|
echo "# POI12| Updating InvenTree"
|
||||||
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && pip install wheel"
|
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && pip install wheel python-dotenv"
|
||||||
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && set -e && invoke update | sed -e 's/^/# POI12| u | /;'"
|
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && set -e && invoke update | sed -e 's/^/# POI12| u | /;'"
|
||||||
|
|
||||||
# Make sure permissions are correct again
|
# Make sure permissions are correct again
|
||||||
@@ -407,13 +407,13 @@ function final_message() {
|
|||||||
echo -e "${SETUP_NGINX_FILE}"
|
echo -e "${SETUP_NGINX_FILE}"
|
||||||
echo -e "Try opening InvenTree with any of \n${INVENTREE_SITE_URL} , http://localhost/ or http://${INVENTREE_IP}/ \n"
|
echo -e "Try opening InvenTree with any of \n${INVENTREE_SITE_URL} , http://localhost/ or http://${INVENTREE_IP}/ \n"
|
||||||
# Print admin user data only if set
|
# Print admin user data only if set
|
||||||
if ["${INVENTREE_ADMIN_USER}" ]; then
|
if [ -n "${INVENTREE_ADMIN_USER}" ]; then
|
||||||
echo -e "Admin user data:"
|
echo -e "Admin user data:"
|
||||||
echo -e " Email: ${INVENTREE_ADMIN_EMAIL}"
|
echo -e " Email: ${INVENTREE_ADMIN_EMAIL}"
|
||||||
echo -e " Username: ${INVENTREE_ADMIN_USER}"
|
echo -e " Username: ${INVENTREE_ADMIN_USER}"
|
||||||
echo -e " Password: ${INVENTREE_ADMIN_PASSWORD}"
|
echo -e " Password: ${INVENTREE_ADMIN_PASSWORD}"
|
||||||
else
|
else
|
||||||
echo -e "No admin set during this operation - depending on the deployment method a admin user might have been created with an initial password saved in `${SETUP_ADMIN_PASSWORD_FILE}`"
|
echo -e "No admin set during this operation - depending on the deployment method a admin user might have been created with an initial password saved in `$SETUP_ADMIN_PASSWORD_FILE`"
|
||||||
fi
|
fi
|
||||||
echo -e "####################################################################################"
|
echo -e "####################################################################################"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ export SETUP_ADMIN_NOCREATION=${SETUP_ADMIN_NOCREATION:-false}
|
|||||||
# SETUP_PYTHON can be set to use a different python version
|
# SETUP_PYTHON can be set to use a different python version
|
||||||
|
|
||||||
# get base info
|
# get base info
|
||||||
|
detect_ip
|
||||||
detect_envs
|
detect_envs
|
||||||
detect_docker
|
detect_docker
|
||||||
detect_initcmd
|
detect_initcmd
|
||||||
detect_ip
|
|
||||||
detect_python
|
detect_python
|
||||||
|
|
||||||
# Check if we are updating and need to alert
|
# Check if we are updating and need to alert
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ For example:
|
|||||||
|
|
||||||
The following user interface feature types are available:
|
The following user interface feature types are available:
|
||||||
|
|
||||||
|
### Spotlight Actions
|
||||||
|
|
||||||
|
Inject custom actions into the InvenTree "spotlight" search functionality by implementing the `get_ui_spotlight_actions` method:
|
||||||
|
|
||||||
|
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_spotlight_actions
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
extra:
|
||||||
|
show_source: True
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
### Dashboard Items
|
### Dashboard Items
|
||||||
|
|
||||||
The InvenTree dashboard is a collection of "items" which are displayed on the main dashboard page. Custom dashboard items can be added to the dashboard by implementing the `get_ui_dashboard_items` method:
|
The InvenTree dashboard is a collection of "items" which are displayed on the main dashboard page. Custom dashboard items can be added to the dashboard by implementing the `get_ui_dashboard_items` method:
|
||||||
|
|||||||
@@ -261,16 +261,38 @@ Total Price: {% render_currency order.total_price currency='NZD' decimal_places=
|
|||||||
{% endraw %}
|
{% endraw %}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### convert_currency
|
||||||
|
|
||||||
|
To convert a currency value from one currency to another, use the `convert_currency` helper function:
|
||||||
|
|
||||||
|
::: report.templatetags.report.convert_currency
|
||||||
|
options:
|
||||||
|
show_docstring_description: false
|
||||||
|
show_source: False
|
||||||
|
|
||||||
|
!!! info "Data Types"
|
||||||
|
The `money` parameter must be `Money` class instance. If not, an error will be raised.
|
||||||
|
|
||||||
|
### create_currency
|
||||||
|
|
||||||
|
Create a `currency` instance using the `create_currency` helper function. This returns a `Money` class instance based on the provided amount and currency type.
|
||||||
|
|
||||||
|
::: report.templatetags.report.create_currency
|
||||||
|
options:
|
||||||
|
show_docstring_description: false
|
||||||
|
show_source: False
|
||||||
|
|
||||||
## Maths Operations
|
## Maths Operations
|
||||||
|
|
||||||
Simple mathematical operators are available, as demonstrated in the example template below. These operators can be used to perform basic arithmetic operations within the report template.
|
Simple mathematical operators are available, as demonstrated in the example template below. These operators can be used to perform basic arithmetic operations within the report template.
|
||||||
|
|
||||||
### Input Types
|
!!! info "Input Types"
|
||||||
|
These mathematical functions accept inputs of various input types, and attempt to perform the operation accordingly. Note that any inputs which are provided as strings or numbers will be converted to `Decimal` class types before the operation is performed.
|
||||||
These mathematical functions accept inputs of various input types, and attempt to perform the operation accordingly. Note that any inputs which are provided as strings or numbers will be converted to `Decimal` class types before the operation is performed.
|
|
||||||
|
|
||||||
### add
|
### add
|
||||||
|
|
||||||
|
Add two numbers together using the `add` helper function:
|
||||||
|
|
||||||
::: report.templatetags.report.add
|
::: report.templatetags.report.add
|
||||||
options:
|
options:
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
@@ -278,6 +300,8 @@ These mathematical functions accept inputs of various input types, and attempt t
|
|||||||
|
|
||||||
### subtract
|
### subtract
|
||||||
|
|
||||||
|
Subtract one number from another using the `subtract` helper function:
|
||||||
|
|
||||||
::: report.templatetags.report.subtract
|
::: report.templatetags.report.subtract
|
||||||
options:
|
options:
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
@@ -285,6 +309,8 @@ These mathematical functions accept inputs of various input types, and attempt t
|
|||||||
|
|
||||||
### multiply
|
### multiply
|
||||||
|
|
||||||
|
Multiply two numbers together using the `multiply` helper function:
|
||||||
|
|
||||||
::: report.templatetags.report.multiply
|
::: report.templatetags.report.multiply
|
||||||
options:
|
options:
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
@@ -292,6 +318,8 @@ These mathematical functions accept inputs of various input types, and attempt t
|
|||||||
|
|
||||||
### divide
|
### divide
|
||||||
|
|
||||||
|
Divide one number by another using the `divide` helper function:
|
||||||
|
|
||||||
::: report.templatetags.report.divide
|
::: report.templatetags.report.divide
|
||||||
options:
|
options:
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
@@ -299,6 +327,8 @@ These mathematical functions accept inputs of various input types, and attempt t
|
|||||||
|
|
||||||
### modulo
|
### modulo
|
||||||
|
|
||||||
|
Perform a modulo operation using the `modulo` helper function:
|
||||||
|
|
||||||
::: report.templatetags.report.modulo
|
::: report.templatetags.report.modulo
|
||||||
options:
|
options:
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
|
|||||||
@@ -380,6 +380,44 @@ Database and media backups **require** a local directory for storage. This direc
|
|||||||
|
|
||||||
Alternatively this location can be specified with the `INVENTREE_BACKUP_DIR` environment variable.
|
Alternatively this location can be specified with the `INVENTREE_BACKUP_DIR` environment variable.
|
||||||
|
|
||||||
|
|
||||||
|
### Storage backends
|
||||||
|
|
||||||
|
It is also possible to use alternative storage backends for static and media files, at the moment there is direct provide direct support bundled for S3 and SFTP. Google cloud storage and Azure blob storage would also be supported by the [used library](https://django-storages.readthedocs.io), but require additional packages to be installed.
|
||||||
|
|
||||||
|
| Environment Variable | Configuration File | Description | Default |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| INVENTREE_STORAGE_TARGET | storage.target | Storage target to use for static and media files, valid options: local, s3, sftp | local |
|
||||||
|
|
||||||
|
#### S3
|
||||||
|
|
||||||
|
| Environment Variable | Configuration File | Description | Default |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| INVENTREE_S3_ACCESS_KEY | storage.s3.access_key | Access key | *Not specified* |
|
||||||
|
| INVENTREE_S3_SECRET_KEY | storage.s3.secret_key | Secret key |
|
||||||
|
| *Not specified* |
|
||||||
|
| INVENTREE_S3_BUCKET_NAME | storage.s3.bucket_name | Bucket name, required by most providers |
|
||||||
|
| *Not specified* |
|
||||||
|
| INVENTREE_S3_REGION_NAME | storage.s3.region_name | S3 region name |
|
||||||
|
| *Not specified* |
|
||||||
|
| INVENTREE_S3_ENDPOINT_URL | storage.s3.endpoint_url | Custom S3 endpoint URL, defaults to AWS endpoints if not set |
|
||||||
|
| *Not specified* |
|
||||||
|
| INVENTREE_S3_LOCATION | storage.s3.location | Sub-Location that should be used | inventree-server |
|
||||||
|
| INVENTREE_S3_DEFAULT_ACL | storage.s3.default_acl | Default ACL for uploaded files, defaults to provider default if not set | *Not specified* |
|
||||||
|
| INVENTREE_S3_VERIFY_SSL | storage.s3.verify_ssl | Verify SSL certificate for S3 endpoint | True |
|
||||||
|
| INVENTREE_S3_OVERWRITE | storage.s3.overwrite | Overwrite existing files in S3 bucket | False |
|
||||||
|
| INVENTREE_S3_VIRTUAL | storage.s3.virtual | Use virtual addressing style - by default False -> `path` style, `virtual` style if True | False |
|
||||||
|
|
||||||
|
#### SFTP
|
||||||
|
|
||||||
|
| Environment Variable | Configuration File | Description | Default |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| INVENTREE_SFTP_HOST | storage.sftp.host | SFTP host | *Not specified* |
|
||||||
|
| INVENTREE_SFTP_PARAMS | storage.sftp.params | SFTP connection parameters, see https://docs.paramiko.org/en/latest/api/client.html#paramiko.client.SSHClient.connect; e.g. `{'port': 22, 'user': 'usr', 'password': 'pwd'}` | *Not specified* |
|
||||||
|
| INVENTREE_SFTP_UID | storage.sftp.uid | SFTP user ID - not required | *Not specified* |
|
||||||
|
| INVENTREE_SFTP_GID | storage.sftp.gid | SFTP group ID - not required | *Not specified* |
|
||||||
|
| INVENTREE_SFTP_LOCATION | storage.sftp.location | Sub-Location that should be used | inventree-server |
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
InvenTree provides allowance for additional sign-in options. The following options are not enabled by default, and care must be taken by the system administrator when configuring these settings.
|
InvenTree provides allowance for additional sign-in options. The following options are not enabled by default, and care must be taken by the system administrator when configuring these settings.
|
||||||
@@ -387,7 +425,7 @@ InvenTree provides allowance for additional sign-in options. The following optio
|
|||||||
| Environment Variable | Configuration File | Description | Default |
|
| Environment Variable | Configuration File | Description | Default |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| INVENTREE_MFA_ENABLED | mfa_enabled | Enable or disable multi-factor authentication support for the InvenTree server | True |
|
| INVENTREE_MFA_ENABLED | mfa_enabled | Enable or disable multi-factor authentication support for the InvenTree server | True |
|
||||||
| INVENTREE_MFA_SUPPORTED_TYPES | mfa_supported_types | List of supported multi-factor authentication types | recovery_codes,totp |
|
| INVENTREE_MFA_SUPPORTED_TYPES | mfa_supported_types | List of supported multi-factor authentication types | recovery_codes,totp,webauthn |
|
||||||
|
|
||||||
### Single Sign On
|
### Single Sign On
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ title: InvenTree Installer
|
|||||||
## Installer
|
## Installer
|
||||||
The InvenTree installer automates the installation procedure for a production InvenTree server.
|
The InvenTree installer automates the installation procedure for a production InvenTree server.
|
||||||
|
|
||||||
Supported OSs are Debian 11 and Ubuntu 20.04 LTS.
|
Supported OSs are Debian 10, 11, 12 and Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS.
|
||||||
|
|
||||||
### Quick Script
|
### Quick Script
|
||||||
|
|
||||||
|
|||||||
+7
-10
@@ -334,17 +334,17 @@ mkdocs-get-deps==0.2.0 \
|
|||||||
--hash=sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c \
|
--hash=sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c \
|
||||||
--hash=sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134
|
--hash=sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134
|
||||||
# via mkdocs
|
# via mkdocs
|
||||||
mkdocs-git-revision-date-localized-plugin==1.4.7 \
|
mkdocs-git-revision-date-localized-plugin==1.5.0 \
|
||||||
--hash=sha256:056c0a90242409148f1dc94d5c9d2c25b5b8ddd8de45489fa38f7fa7ccad2bc4 \
|
--hash=sha256:17345ccfdf69a1905dc96fb1070dce82d03a1eb6b0d48f958081a7589ce3c248 \
|
||||||
--hash=sha256:10a49eff1e1c3cb766e054b9d8360c904ce4fe8c33ac3f6cc083ac6459c91953
|
--hash=sha256:933f9e35a8c135b113f21bb57610d82e9b7bcc71dd34fb06a029053c97e99656
|
||||||
# via -r docs/requirements.in
|
# via -r docs/requirements.in
|
||||||
mkdocs-include-markdown-plugin==7.2.0 \
|
mkdocs-include-markdown-plugin==7.2.0 \
|
||||||
--hash=sha256:4a67a91ade680dc0e15f608e5b6343bec03372ffa112c40a4254c1bfb10f42f3 \
|
--hash=sha256:4a67a91ade680dc0e15f608e5b6343bec03372ffa112c40a4254c1bfb10f42f3 \
|
||||||
--hash=sha256:d56cdaeb2d113fb66ed0fe4fb7af1da889926b0b9872032be24e19bbb09c9f5b
|
--hash=sha256:d56cdaeb2d113fb66ed0fe4fb7af1da889926b0b9872032be24e19bbb09c9f5b
|
||||||
# via -r docs/requirements.in
|
# via -r docs/requirements.in
|
||||||
mkdocs-macros-plugin==1.4.0 \
|
mkdocs-macros-plugin==1.4.1 \
|
||||||
--hash=sha256:630de99fe14d39c26c7915725b39bb7730058c622f547226432eb371cabff33c \
|
--hash=sha256:55a9c93871e3744cdeb0736316783d60830a6d5d97b1132364e6b491607f2332 \
|
||||||
--hash=sha256:687e710988736731d1a059633fc34cf66d21e57a07801bdd1991419199e0677c
|
--hash=sha256:5a9e483f6056fe7ad0923802affe699233ca468672e20a9640dba237165b3240
|
||||||
# via -r docs/requirements.in
|
# via -r docs/requirements.in
|
||||||
mkdocs-material==9.6.22 \
|
mkdocs-material==9.6.22 \
|
||||||
--hash=sha256:14ac5f72d38898b2f98ac75a5531aaca9366eaa427b0f49fc2ecf04d99b7ad84 \
|
--hash=sha256:14ac5f72d38898b2f98ac75a5531aaca9366eaa427b0f49fc2ecf04d99b7ad84 \
|
||||||
@@ -419,10 +419,6 @@ python-dateutil==2.9.0.post0 \
|
|||||||
# via
|
# via
|
||||||
# ghp-import
|
# ghp-import
|
||||||
# mkdocs-macros-plugin
|
# mkdocs-macros-plugin
|
||||||
pytz==2025.2 \
|
|
||||||
--hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \
|
|
||||||
--hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
|
|
||||||
# via mkdocs-git-revision-date-localized-plugin
|
|
||||||
pyyaml==6.0.3 \
|
pyyaml==6.0.3 \
|
||||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||||
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
|
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
|
||||||
@@ -512,6 +508,7 @@ requests==2.32.5 \
|
|||||||
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
|
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
|
||||||
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
|
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
|
||||||
# via
|
# via
|
||||||
|
# mkdocs-macros-plugin
|
||||||
# mkdocs-material
|
# mkdocs-material
|
||||||
# mkdocs-mermaid2-plugin
|
# mkdocs-mermaid2-plugin
|
||||||
rich==14.1.0 \
|
rich==14.1.0 \
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# Dummy requirements file to trigger the package pipeline
|
|
||||||
# The backend requirements file is located in src/backend/requirements.txt
|
|
||||||
#
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
python-3.10.7
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""Middleware to require 2FA for users."""
|
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from allauth.account.authentication import get_authentication_records
|
|
||||||
from allauth.mfa.utils import is_mfa_enabled
|
|
||||||
from allauth.mfa.webauthn.internal.flows import did_use_passwordless_login
|
|
||||||
|
|
||||||
|
|
||||||
def is_multifactor_logged_in(request: HttpRequest) -> bool:
|
|
||||||
"""Check if the user is logged in with multifactor authentication."""
|
|
||||||
authns = get_authentication_records(request)
|
|
||||||
|
|
||||||
return is_mfa_enabled(request.user) and (
|
|
||||||
did_use_passwordless_login(request)
|
|
||||||
or any(record.get('method') == 'mfa' for record in authns)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AllUserRequire2FAMiddleware(MiddlewareMixin):
|
|
||||||
"""Ensure that users have two-factor authentication enabled before they have access restricted endpoints.
|
|
||||||
|
|
||||||
Adapted from https://github.com/pennersr/django-allauth/issues/3649
|
|
||||||
"""
|
|
||||||
|
|
||||||
allowed_pages = [
|
|
||||||
'api-user-meta',
|
|
||||||
'api-user-me',
|
|
||||||
'api-user-roles',
|
|
||||||
'api-inventree-info',
|
|
||||||
'api-token',
|
|
||||||
# web platform urls
|
|
||||||
'password_reset_confirm',
|
|
||||||
'web',
|
|
||||||
'web-wildcard',
|
|
||||||
'web-assets',
|
|
||||||
]
|
|
||||||
app_names = ['headless']
|
|
||||||
require_2fa_message = _(
|
|
||||||
'You must enable two-factor authentication before doing anything else.'
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_require_2fa(self, request: HttpRequest) -> HttpResponse:
|
|
||||||
"""Force user to mfa activation."""
|
|
||||||
return JsonResponse({'id': 'mfa_register'}, status=401)
|
|
||||||
|
|
||||||
def is_allowed_page(self, request: HttpRequest) -> bool:
|
|
||||||
"""Check if the current page can be accessed without mfa."""
|
|
||||||
match = request.resolver_match
|
|
||||||
return (
|
|
||||||
None
|
|
||||||
if match is None
|
|
||||||
else any(ref in self.app_names for ref in match.app_names)
|
|
||||||
or match.url_name in self.allowed_pages
|
|
||||||
or match.route == 'favicon.ico'
|
|
||||||
)
|
|
||||||
|
|
||||||
def enforce_2fa(self, request):
|
|
||||||
"""Check if 2fa should be enforced for this request."""
|
|
||||||
return True
|
|
||||||
|
|
||||||
def process_view(
|
|
||||||
self, request: HttpRequest, view_func, view_args, view_kwargs
|
|
||||||
) -> HttpResponse:
|
|
||||||
"""If set up enforce 2fa registration."""
|
|
||||||
if request.user.is_anonymous:
|
|
||||||
return None
|
|
||||||
if self.is_allowed_page(request):
|
|
||||||
return None
|
|
||||||
if is_multifactor_logged_in(request):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if self.enforce_2fa(request):
|
|
||||||
return self.on_require_2fa(request)
|
|
||||||
return None
|
|
||||||
@@ -95,8 +95,8 @@ class LicenseView(APIView):
|
|||||||
@extend_schema(responses={200: OpenApiResponse(response=LicenseViewSerializer)})
|
@extend_schema(responses={200: OpenApiResponse(response=LicenseViewSerializer)})
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Return information about the InvenTree server."""
|
"""Return information about the InvenTree server."""
|
||||||
backend = Path(__file__).parent.joinpath('licenses.txt')
|
backend = InvenTree.config.get_base_dir().joinpath('InvenTree', 'licenses.txt')
|
||||||
frontend = Path(__file__).parent.parent.joinpath(
|
frontend = InvenTree.config.get_base_dir().joinpath(
|
||||||
'web/static/web/.vite/dependencies.json'
|
'web/static/web/.vite/dependencies.json'
|
||||||
)
|
)
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 420
|
INVENTREE_API_VERSION = 423
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v423 -> 2025-11-05 : https://github.com/inventree/InvenTree/pull/10772
|
||||||
|
- Adds "category_detail" field to BomItem API endpoints
|
||||||
|
- Adds "category_detail" field to BuildLine API endpoints
|
||||||
|
|
||||||
|
v422 -> 2025-11-03 : https://github.com/inventree/InvenTree/pull/10750
|
||||||
|
- Adds ability to search StockItem API by supplier SKU
|
||||||
|
- Adds ability to search StockItem API by manufacturer MPN
|
||||||
|
|
||||||
|
v421 -> 2025-10-31 : https://github.com/inventree/InvenTree/pull/10724
|
||||||
|
- Allow upload of attachments against SupplierPart objects via the API
|
||||||
|
|
||||||
v420 -> 2025-10-26 : https://github.com/inventree/InvenTree/pull/10675
|
v420 -> 2025-10-26 : https://github.com/inventree/InvenTree/pull/10675
|
||||||
- Adds optional "customer_detail" filter to SalesOrderShipment API endpoint
|
- Adds optional "customer_detail" filter to SalesOrderShipment API endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,18 @@ class InvenTreeConfig(AppConfig):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not InvenTree.tasks.check_for_migrations():
|
if not InvenTree.tasks.check_for_migrations():
|
||||||
|
# Detect if this an empty database - if so, start with a fresh migration
|
||||||
|
if (
|
||||||
|
settings.DOCKER
|
||||||
|
and not InvenTree.ready.isInTestMode()
|
||||||
|
and not InvenTree.ready.isRunningMigrations()
|
||||||
|
and InvenTree.tasks.get_migration_count() == 0
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
'INVE-W8: Empty database detected - trying to run migrations'
|
||||||
|
)
|
||||||
|
InvenTree.tasks.check_for_migrations(force_run=True)
|
||||||
|
else:
|
||||||
logger.error('INVE-W8: Database Migrations required')
|
logger.error('INVE-W8: Database Migrations required')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
MIGRATIONS_CHECK_DONE = True
|
MIGRATIONS_CHECK_DONE = True
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from common.currency import currency_code_default
|
from common.currency import currency_code_default
|
||||||
|
|
||||||
|
from .setting.storages import StorageBackends
|
||||||
from .settings import MEDIA_URL, STATIC_URL
|
from .settings import MEDIA_URL, STATIC_URL
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@@ -176,6 +177,8 @@ def constructPathString(path: list[str], max_chars: int = 250) -> str:
|
|||||||
|
|
||||||
def getMediaUrl(filename):
|
def getMediaUrl(filename):
|
||||||
"""Return the qualified access path for the given file, under the media directory."""
|
"""Return the qualified access path for the given file, under the media directory."""
|
||||||
|
if settings.STORAGE_TARGET == StorageBackends.S3:
|
||||||
|
return str(filename)
|
||||||
return os.path.join(MEDIA_URL, str(filename))
|
return os.path.join(MEDIA_URL, str(filename))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ from urllib.parse import urlsplit
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
|
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
|
||||||
from django.http import HttpResponse
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.urls import resolve, reverse_lazy
|
from django.urls import resolve, reverse_lazy
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
from django.utils.http import is_same_domain
|
from django.utils.http import is_same_domain
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from error_report.middleware import ExceptionProcessor
|
from error_report.middleware import ExceptionProcessor
|
||||||
|
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from InvenTree.AllUserRequire2FAMiddleware import AllUserRequire2FAMiddleware
|
|
||||||
from InvenTree.cache import create_session_cache, delete_session_cache
|
from InvenTree.cache import create_session_cache, delete_session_cache
|
||||||
from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller
|
from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller
|
||||||
from users.models import ApiToken
|
from users.models import ApiToken
|
||||||
@@ -146,8 +146,80 @@ class AuthRequiredMiddleware:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class Check2FAMiddleware(AllUserRequire2FAMiddleware):
|
class Check2FAMiddleware(MiddlewareMixin):
|
||||||
"""Ensure that mfa is enforced if set so."""
|
"""Ensure that users have two-factor authentication enabled before they have access restricted endpoints.
|
||||||
|
|
||||||
|
Adapted from https://github.com/pennersr/django-allauth/issues/3649
|
||||||
|
"""
|
||||||
|
|
||||||
|
allowed_pages = [
|
||||||
|
'api-user-meta',
|
||||||
|
'api-user-me',
|
||||||
|
'api-user-roles',
|
||||||
|
'api-inventree-info',
|
||||||
|
'api-token',
|
||||||
|
# web platform urls
|
||||||
|
'password_reset_confirm',
|
||||||
|
'index',
|
||||||
|
'web',
|
||||||
|
'web-wildcard',
|
||||||
|
'web-assets',
|
||||||
|
]
|
||||||
|
app_names = ['headless']
|
||||||
|
require_2fa_message = _(
|
||||||
|
'You must enable two-factor authentication before doing anything else.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_require_2fa(self, request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Force user to mfa activation."""
|
||||||
|
return JsonResponse(
|
||||||
|
{'id': 'mfa_register', 'error': self.require_2fa_message}, status=401
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_allowed_page(self, request: HttpRequest) -> bool:
|
||||||
|
"""Check if the current page can be accessed without mfa."""
|
||||||
|
match = request.resolver_match
|
||||||
|
return (
|
||||||
|
None
|
||||||
|
if match is None
|
||||||
|
else any(ref in self.app_names for ref in match.app_names)
|
||||||
|
or match.url_name in self.allowed_pages
|
||||||
|
or match.route == 'favicon.ico'
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_multifactor_logged_in(self, request: HttpRequest) -> bool:
|
||||||
|
"""Check if the user is logged in with multifactor authentication."""
|
||||||
|
from allauth.account.authentication import get_authentication_records
|
||||||
|
from allauth.mfa.utils import is_mfa_enabled
|
||||||
|
from allauth.mfa.webauthn.internal.flows import did_use_passwordless_login
|
||||||
|
|
||||||
|
authns = get_authentication_records(request)
|
||||||
|
|
||||||
|
return is_mfa_enabled(request.user) and (
|
||||||
|
did_use_passwordless_login(request)
|
||||||
|
or any(record.get('method') == 'mfa' for record in authns)
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_view(
|
||||||
|
self, request: HttpRequest, view_func, view_args, view_kwargs
|
||||||
|
) -> HttpResponse:
|
||||||
|
"""Determine if the server is set up enforce 2fa registration."""
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Exit early if MFA is not enabled
|
||||||
|
if not settings.MFA_ENABLED:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if request.user.is_anonymous:
|
||||||
|
return None
|
||||||
|
if self.is_allowed_page(request):
|
||||||
|
return None
|
||||||
|
if self.is_multifactor_logged_in(request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.enforce_2fa(request):
|
||||||
|
return self.on_require_2fa(request)
|
||||||
|
return None
|
||||||
|
|
||||||
def enforce_2fa(self, request):
|
def enforce_2fa(self, request):
|
||||||
"""Use setting to check if MFA should be enforced."""
|
"""Use setting to check if MFA should be enforced."""
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ from common.currency import currency_code_default, currency_code_mappings
|
|||||||
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
|
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
|
|
||||||
|
from .setting.storages import StorageBackends
|
||||||
|
|
||||||
|
|
||||||
# region path filtering
|
# region path filtering
|
||||||
class FilterableSerializerField:
|
class FilterableSerializerField:
|
||||||
@@ -613,6 +615,8 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
|
|||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if settings.STORAGE_TARGET == StorageBackends.S3:
|
||||||
|
return str(value.url)
|
||||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
return os.path.join(str(settings.MEDIA_URL), str(value))
|
||||||
|
|
||||||
|
|
||||||
@@ -627,6 +631,8 @@ class InvenTreeImageSerializerField(serializers.ImageField):
|
|||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if settings.STORAGE_TARGET == StorageBackends.S3:
|
||||||
|
return str(value.url)
|
||||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
return os.path.join(str(settings.MEDIA_URL), str(value))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Sub-setting files."""
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Configuration options for django-markdownify.
|
||||||
|
|
||||||
|
Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def markdownify_config():
|
||||||
|
"""Return configuration dictionary for django-markdownify."""
|
||||||
|
return {
|
||||||
|
'default': {
|
||||||
|
'BLEACH': True,
|
||||||
|
'WHITELIST_ATTRS': ['href', 'src', 'alt'],
|
||||||
|
'MARKDOWN_EXTENSIONS': ['markdown.extensions.extra'],
|
||||||
|
'WHITELIST_TAGS': [
|
||||||
|
'a',
|
||||||
|
'abbr',
|
||||||
|
'b',
|
||||||
|
'blockquote',
|
||||||
|
'code',
|
||||||
|
'em',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'hr',
|
||||||
|
'i',
|
||||||
|
'img',
|
||||||
|
'li',
|
||||||
|
'ol',
|
||||||
|
'p',
|
||||||
|
'pre',
|
||||||
|
's',
|
||||||
|
'strong',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'th',
|
||||||
|
'tr',
|
||||||
|
'td',
|
||||||
|
'ul',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Configuration options for drf-spectacular."""
|
||||||
|
|
||||||
|
from InvenTree.version import inventreeApiVersion
|
||||||
|
|
||||||
|
|
||||||
|
def get_spectacular_settings():
|
||||||
|
"""Return configuration dictionary for drf-spectacular."""
|
||||||
|
return {
|
||||||
|
'TITLE': 'InvenTree API',
|
||||||
|
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
|
||||||
|
'LICENSE': {
|
||||||
|
'name': 'MIT',
|
||||||
|
'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE',
|
||||||
|
},
|
||||||
|
'EXTERNAL_DOCS': {
|
||||||
|
'description': 'More information about InvenTree in the official docs',
|
||||||
|
'url': 'https://docs.inventree.org',
|
||||||
|
},
|
||||||
|
'VERSION': str(inventreeApiVersion()),
|
||||||
|
'SERVE_INCLUDE_SCHEMA': False,
|
||||||
|
'SCHEMA_PATH_PREFIX': '/api/',
|
||||||
|
'POSTPROCESSING_HOOKS': [
|
||||||
|
'drf_spectacular.hooks.postprocess_schema_enums',
|
||||||
|
'InvenTree.schema.postprocess_required_nullable',
|
||||||
|
'InvenTree.schema.postprocess_print_stats',
|
||||||
|
],
|
||||||
|
'ENUM_NAME_OVERRIDES': {
|
||||||
|
'UserTypeEnum': 'users.models.UserProfile.UserType',
|
||||||
|
'TemplateModelTypeEnum': 'report.models.ReportTemplateBase.ModelChoices',
|
||||||
|
'AttachmentModelTypeEnum': 'common.models.Attachment.ModelChoices',
|
||||||
|
'DataImportSessionModelTypeEnum': 'importer.models.DataImportSession.ModelChoices',
|
||||||
|
# Allauth
|
||||||
|
'UnauthorizedStatus': [[401, 401]],
|
||||||
|
'IsTrueEnum': [[True, True]],
|
||||||
|
},
|
||||||
|
# oAuth2
|
||||||
|
'OAUTH2_FLOWS': ['authorizationCode', 'clientCredentials'],
|
||||||
|
'OAUTH2_AUTHORIZATION_URL': '/o/authorize/',
|
||||||
|
'OAUTH2_TOKEN_URL': '/o/token/',
|
||||||
|
'OAUTH2_REFRESH_URL': '/o/revoke_token/',
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Settings for storage backends."""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from InvenTree.config import get_boolean_setting, get_setting
|
||||||
|
|
||||||
|
|
||||||
|
class StorageBackends(str, Enum):
|
||||||
|
"""Enumeration of available storage backends."""
|
||||||
|
|
||||||
|
LOCAL = 'local'
|
||||||
|
S3 = 's3'
|
||||||
|
SFTP = 'sftp'
|
||||||
|
|
||||||
|
|
||||||
|
STORAGE_BACKEND_MAPPING = {
|
||||||
|
StorageBackends.LOCAL: 'django.core.files.storage.FileSystemStorage',
|
||||||
|
StorageBackends.S3: 'storages.backends.s3.S3Storage',
|
||||||
|
StorageBackends.SFTP: 'storages.backends.sftpstorage.SFTPStorage',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def init_storages() -> tuple[str, dict, Optional[str]]:
|
||||||
|
"""Initialize storage backend settings."""
|
||||||
|
target = get_setting(
|
||||||
|
'INVENTREE_STORAGE_TARGET',
|
||||||
|
'storage.target',
|
||||||
|
StorageBackends.LOCAL.value,
|
||||||
|
typecast=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that the target is valid
|
||||||
|
if target not in STORAGE_BACKEND_MAPPING:
|
||||||
|
raise ValueError(f"Invalid storage target: '{target}'")
|
||||||
|
|
||||||
|
options = {}
|
||||||
|
media_url: Optional[str] = None
|
||||||
|
if target == StorageBackends.S3.value:
|
||||||
|
s3_bucket = get_setting(
|
||||||
|
'INVENTREE_S3_BUCKET_NAME', 'storage.s3.bucket_name', None, typecast=str
|
||||||
|
)
|
||||||
|
s3_acl = get_setting(
|
||||||
|
'INVENTREE_S3_DEFAULT_ACL', 'storage.s3.default_acl', None, typecast=str
|
||||||
|
)
|
||||||
|
s3_endpoint = get_setting(
|
||||||
|
'INVENTREE_S3_ENDPOINT_URL', 'storage.s3.endpoint_url', None, typecast=str
|
||||||
|
)
|
||||||
|
s3_location = get_setting(
|
||||||
|
'INVENTREE_S3_LOCATION',
|
||||||
|
'storage.s3.location',
|
||||||
|
'inventree-server',
|
||||||
|
typecast=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
media_url = f'{s3_endpoint}/{s3_bucket}/{s3_location}/'
|
||||||
|
options = {
|
||||||
|
'access_key': get_setting(
|
||||||
|
'INVENTREE_S3_ACCESS_KEY', 'storage.s3.access_key', None, typecast=str
|
||||||
|
),
|
||||||
|
'secret_key': get_setting(
|
||||||
|
'INVENTREE_S3_SECRET_KEY', 'storage.s3.secret_key', None, typecast=str
|
||||||
|
),
|
||||||
|
'bucket_name': s3_bucket,
|
||||||
|
'default_acl': s3_acl,
|
||||||
|
'region_name': get_setting(
|
||||||
|
'INVENTREE_S3_REGION_NAME', 'storage.s3.region_name', None, typecast=str
|
||||||
|
),
|
||||||
|
'endpoint_url': s3_endpoint,
|
||||||
|
'verify': get_boolean_setting(
|
||||||
|
'INVENTREE_S3_VERIFY_SSL', 'storage.s3.verify_ssl', True
|
||||||
|
),
|
||||||
|
'location': s3_location,
|
||||||
|
'file_overwrite': get_boolean_setting(
|
||||||
|
'INVENTREE_S3_OVERWRITE', 'storage.s3.overwrite', True
|
||||||
|
),
|
||||||
|
'addressing_style': 'virtual'
|
||||||
|
if get_boolean_setting('INVENTREE_S3_VIRTUAL', 'storage.s3.virtual', False)
|
||||||
|
else 'path',
|
||||||
|
'object_parameters': {'CacheControl': 'public,max-age=86400'},
|
||||||
|
}
|
||||||
|
elif target == StorageBackends.SFTP:
|
||||||
|
options = {
|
||||||
|
'host': get_setting('INVENTREE_SFTP_HOST', 'sftp.host', None, typecast=str),
|
||||||
|
'uid': get_setting('INVENTREE_SFTP_UID', 'sftp.uid', None, typecast=int),
|
||||||
|
'gid': get_setting('INVENTREE_SFTP_GID', 'sftp.gid', None, typecast=int),
|
||||||
|
'location': get_setting(
|
||||||
|
'INVENTREE_SFTP_LOCATION',
|
||||||
|
'sftp.location',
|
||||||
|
'inventree-server',
|
||||||
|
typecast=str,
|
||||||
|
),
|
||||||
|
'params': get_setting(
|
||||||
|
'INVENTREE_SFTP_PARAMS', 'sftp.params', {}, typecast=dict
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
'default': {
|
||||||
|
'BACKEND': STORAGE_BACKEND_MAPPING.get(
|
||||||
|
target, STORAGE_BACKEND_MAPPING[StorageBackends.LOCAL]
|
||||||
|
),
|
||||||
|
'OPTIONS': options,
|
||||||
|
},
|
||||||
|
'staticfiles': {
|
||||||
|
'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media_url,
|
||||||
|
)
|
||||||
@@ -34,14 +34,11 @@ from InvenTree.config import (
|
|||||||
)
|
)
|
||||||
from InvenTree.ready import isInMainThread
|
from InvenTree.ready import isInMainThread
|
||||||
from InvenTree.sentry import default_sentry_dsn, init_sentry
|
from InvenTree.sentry import default_sentry_dsn, init_sentry
|
||||||
from InvenTree.version import (
|
from InvenTree.version import checkMinPythonVersion, inventreeCommitHash
|
||||||
checkMinPythonVersion,
|
|
||||||
inventreeApiVersion,
|
|
||||||
inventreeCommitHash,
|
|
||||||
)
|
|
||||||
from users.oauth2_scopes import oauth2_scopes
|
from users.oauth2_scopes import oauth2_scopes
|
||||||
|
|
||||||
from . import config, locales
|
from . import config
|
||||||
|
from .setting import locales, markdown, spectacular, storages
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import django_stubs_ext
|
import django_stubs_ext
|
||||||
@@ -264,13 +261,9 @@ DBBACKUP_EMAIL_SUBJECT_PREFIX = InvenTree.backup.backup_email_prefix()
|
|||||||
DBBACKUP_CONNECTORS = {'default': InvenTree.backup.get_backup_connector_options()}
|
DBBACKUP_CONNECTORS = {'default': InvenTree.backup.get_backup_connector_options()}
|
||||||
|
|
||||||
# Data storage options
|
# Data storage options
|
||||||
STORAGES = {
|
DBBACKUP_STORAGE_CONFIG = {
|
||||||
'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
|
|
||||||
'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'},
|
|
||||||
'dbbackup': {
|
|
||||||
'BACKEND': InvenTree.backup.get_backup_storage_backend(),
|
'BACKEND': InvenTree.backup.get_backup_storage_backend(),
|
||||||
'OPTIONS': InvenTree.backup.get_backup_storage_options(),
|
'OPTIONS': InvenTree.backup.get_backup_storage_options(),
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Enable django admin interface?
|
# Enable django admin interface?
|
||||||
@@ -343,6 +336,7 @@ INSTALLED_APPS = [
|
|||||||
'django_ical', # For exporting calendars
|
'django_ical', # For exporting calendars
|
||||||
'django_mailbox', # For email import
|
'django_mailbox', # For email import
|
||||||
'anymail', # For email sending/receiving via ESPs
|
'anymail', # For email sending/receiving via ESPs
|
||||||
|
'storages',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = CONFIG.get(
|
MIDDLEWARE = CONFIG.get(
|
||||||
@@ -1380,15 +1374,22 @@ ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
|
|||||||
|
|
||||||
HEADLESS_ONLY = True
|
HEADLESS_ONLY = True
|
||||||
HEADLESS_CLIENTS = 'browser'
|
HEADLESS_CLIENTS = 'browser'
|
||||||
MFA_ENABLED = get_boolean_setting(
|
MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True)
|
||||||
'INVENTREE_MFA_ENABLED', 'mfa_enabled', True
|
|
||||||
) # TODO re-implement
|
if not MFA_ENABLED:
|
||||||
MFA_SUPPORTED_TYPES = get_setting(
|
MIDDLEWARE.remove('InvenTree.middleware.Check2FAMiddleware')
|
||||||
|
|
||||||
|
MFA_SUPPORTED_TYPES = (
|
||||||
|
get_setting(
|
||||||
'INVENTREE_MFA_SUPPORTED_TYPES',
|
'INVENTREE_MFA_SUPPORTED_TYPES',
|
||||||
'mfa_supported_types',
|
'mfa_supported_types',
|
||||||
['totp', 'recovery_codes', 'webauthn'],
|
['totp', 'recovery_codes', 'webauthn'],
|
||||||
typecast=list,
|
typecast=list,
|
||||||
|
)
|
||||||
|
if MFA_ENABLED
|
||||||
|
else []
|
||||||
)
|
)
|
||||||
|
|
||||||
MFA_TRUST_ENABLED = True
|
MFA_TRUST_ENABLED = True
|
||||||
MFA_PASSKEY_LOGIN_ENABLED = True
|
MFA_PASSKEY_LOGIN_ENABLED = True
|
||||||
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG
|
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG
|
||||||
@@ -1401,42 +1402,7 @@ LOGOUT_REDIRECT_URL = get_setting(
|
|||||||
# Markdownify configuration
|
# Markdownify configuration
|
||||||
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
|
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
|
||||||
|
|
||||||
MARKDOWNIFY = {
|
MARKDOWNIFY = markdown.markdownify_config()
|
||||||
'default': {
|
|
||||||
'BLEACH': True,
|
|
||||||
'WHITELIST_ATTRS': ['href', 'src', 'alt'],
|
|
||||||
'MARKDOWN_EXTENSIONS': ['markdown.extensions.extra'],
|
|
||||||
'WHITELIST_TAGS': [
|
|
||||||
'a',
|
|
||||||
'abbr',
|
|
||||||
'b',
|
|
||||||
'blockquote',
|
|
||||||
'code',
|
|
||||||
'em',
|
|
||||||
'h1',
|
|
||||||
'h2',
|
|
||||||
'h3',
|
|
||||||
'h4',
|
|
||||||
'h5',
|
|
||||||
'hr',
|
|
||||||
'i',
|
|
||||||
'img',
|
|
||||||
'li',
|
|
||||||
'ol',
|
|
||||||
'p',
|
|
||||||
'pre',
|
|
||||||
's',
|
|
||||||
'strong',
|
|
||||||
'table',
|
|
||||||
'thead',
|
|
||||||
'tbody',
|
|
||||||
'th',
|
|
||||||
'tr',
|
|
||||||
'td',
|
|
||||||
'ul',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ignore these error types for in-database error logging
|
# Ignore these error types for in-database error logging
|
||||||
IGNORED_ERRORS = [Http404, HttpResponseGone, django.core.exceptions.PermissionDenied]
|
IGNORED_ERRORS = [Http404, HttpResponseGone, django.core.exceptions.PermissionDenied]
|
||||||
@@ -1513,40 +1479,8 @@ SESAME_MAX_AGE = 300
|
|||||||
LOGIN_REDIRECT_URL = '/api/auth/login-redirect/'
|
LOGIN_REDIRECT_URL = '/api/auth/login-redirect/'
|
||||||
|
|
||||||
# Configuration for API schema generation / oAuth2
|
# Configuration for API schema generation / oAuth2
|
||||||
SPECTACULAR_SETTINGS = {
|
SPECTACULAR_SETTINGS = spectacular.get_spectacular_settings()
|
||||||
'TITLE': 'InvenTree API',
|
|
||||||
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
|
|
||||||
'LICENSE': {
|
|
||||||
'name': 'MIT',
|
|
||||||
'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE',
|
|
||||||
},
|
|
||||||
'EXTERNAL_DOCS': {
|
|
||||||
'description': 'More information about InvenTree in the official docs',
|
|
||||||
'url': 'https://docs.inventree.org',
|
|
||||||
},
|
|
||||||
'VERSION': str(inventreeApiVersion()),
|
|
||||||
'SERVE_INCLUDE_SCHEMA': False,
|
|
||||||
'SCHEMA_PATH_PREFIX': '/api/',
|
|
||||||
'POSTPROCESSING_HOOKS': [
|
|
||||||
'drf_spectacular.hooks.postprocess_schema_enums',
|
|
||||||
'InvenTree.schema.postprocess_required_nullable',
|
|
||||||
'InvenTree.schema.postprocess_print_stats',
|
|
||||||
],
|
|
||||||
'ENUM_NAME_OVERRIDES': {
|
|
||||||
'UserTypeEnum': 'users.models.UserProfile.UserType',
|
|
||||||
'TemplateModelTypeEnum': 'report.models.ReportTemplateBase.ModelChoices',
|
|
||||||
'AttachmentModelTypeEnum': 'common.models.Attachment.ModelChoices',
|
|
||||||
'DataImportSessionModelTypeEnum': 'importer.models.DataImportSession.ModelChoices',
|
|
||||||
# Allauth
|
|
||||||
'UnauthorizedStatus': [[401, 401]],
|
|
||||||
'IsTrueEnum': [[True, True]],
|
|
||||||
},
|
|
||||||
# oAuth2
|
|
||||||
'OAUTH2_FLOWS': ['authorizationCode', 'clientCredentials'],
|
|
||||||
'OAUTH2_AUTHORIZATION_URL': '/o/authorize/',
|
|
||||||
'OAUTH2_TOKEN_URL': '/o/token/',
|
|
||||||
'OAUTH2_REFRESH_URL': '/o/revoke_token/',
|
|
||||||
}
|
|
||||||
OAUTH2_PROVIDER = {
|
OAUTH2_PROVIDER = {
|
||||||
# default scopes
|
# default scopes
|
||||||
'SCOPES': oauth2_scopes,
|
'SCOPES': oauth2_scopes,
|
||||||
@@ -1562,3 +1496,11 @@ OAUTH2_CHECK_EXCLUDED = [ # This setting mutes schema checks for these rule/met
|
|||||||
|
|
||||||
if SITE_URL and not TESTING: # pragma: no cover
|
if SITE_URL and not TESTING: # pragma: no cover
|
||||||
SPECTACULAR_SETTINGS['SERVERS'] = [{'url': SITE_URL}]
|
SPECTACULAR_SETTINGS['SERVERS'] = [{'url': SITE_URL}]
|
||||||
|
|
||||||
|
# Storage backends
|
||||||
|
STORAGE_TARGET, STORAGES, _media = storages.init_storages()
|
||||||
|
if 'dbbackup' not in STORAGES:
|
||||||
|
STORAGES['dbbackup'] = DBBACKUP_STORAGE_CONFIG
|
||||||
|
if _media:
|
||||||
|
MEDIA_URL = _media
|
||||||
|
PRESIGNED_URL_EXPIRATION = 600
|
||||||
|
|||||||
@@ -661,6 +661,12 @@ def get_migration_plan():
|
|||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def get_migration_count():
|
||||||
|
"""Returns the number of all detected migrations."""
|
||||||
|
executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
|
||||||
|
return executor.loader.applied_migrations
|
||||||
|
|
||||||
|
|
||||||
@tracer.start_as_current_span('check_for_migrations')
|
@tracer.start_as_current_span('check_for_migrations')
|
||||||
@scheduled_task(ScheduledTask.DAILY)
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def check_for_migrations(force: bool = False, reload_registry: bool = True) -> bool:
|
def check_for_migrations(force: bool = False, reload_registry: bool = True) -> bool:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from django.conf import settings
|
|||||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||||
|
|
||||||
# InvenTree software version
|
# InvenTree software version
|
||||||
INVENTREE_SW_VERSION = '1.1.0 dev'
|
INVENTREE_SW_VERSION = '1.2.0 dev'
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ def auth_request(request):
|
|||||||
|
|
||||||
Useful for (for example) redirecting authentication requests through django's permission framework.
|
Useful for (for example) redirecting authentication requests through django's permission framework.
|
||||||
"""
|
"""
|
||||||
if request.user and request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
return HttpResponse(status=200)
|
|
||||||
return HttpResponse(status=403)
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
if not request.user.is_active:
|
||||||
|
# Reject requests from inactive users
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
# User is authenticated and active
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|||||||
@@ -597,6 +597,7 @@ class BuildLineList(
|
|||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'part',
|
'part',
|
||||||
'allocated',
|
'allocated',
|
||||||
|
'category',
|
||||||
'consumed',
|
'consumed',
|
||||||
'reference',
|
'reference',
|
||||||
'quantity',
|
'quantity',
|
||||||
@@ -613,6 +614,7 @@ class BuildLineList(
|
|||||||
'part': 'bom_item__sub_part__name',
|
'part': 'bom_item__sub_part__name',
|
||||||
'reference': 'bom_item__reference',
|
'reference': 'bom_item__reference',
|
||||||
'unit_quantity': 'bom_item__quantity',
|
'unit_quantity': 'bom_item__quantity',
|
||||||
|
'category': 'bom_item__sub_part__category__name',
|
||||||
'consumable': 'bom_item__consumable',
|
'consumable': 'bom_item__consumable',
|
||||||
'optional': 'bom_item__optional',
|
'optional': 'bom_item__optional',
|
||||||
'trackable': 'bom_item__sub_part__trackable',
|
'trackable': 'bom_item__sub_part__trackable',
|
||||||
|
|||||||
@@ -1339,6 +1339,7 @@ class BuildLineSerializer(
|
|||||||
'bom_item_detail',
|
'bom_item_detail',
|
||||||
'assembly_detail',
|
'assembly_detail',
|
||||||
'part_detail',
|
'part_detail',
|
||||||
|
'category_detail',
|
||||||
'build_detail',
|
'build_detail',
|
||||||
]
|
]
|
||||||
read_only_fields = ['build', 'bom_item', 'allocations']
|
read_only_fields = ['build', 'bom_item', 'allocations']
|
||||||
@@ -1430,6 +1431,17 @@ class BuildLineSerializer(
|
|||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
category_detail = enable_filter(
|
||||||
|
part_serializers.CategorySerializer(
|
||||||
|
label=_('Category'),
|
||||||
|
source='bom_item.sub_part.category',
|
||||||
|
many=False,
|
||||||
|
read_only=True,
|
||||||
|
allow_null=True,
|
||||||
|
),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
build_detail = enable_filter(
|
build_detail = enable_filter(
|
||||||
BuildSerializer(
|
BuildSerializer(
|
||||||
label=_('Build'),
|
label=_('Build'),
|
||||||
@@ -1505,6 +1517,7 @@ class BuildLineSerializer(
|
|||||||
'bom_item',
|
'bom_item',
|
||||||
'bom_item__part',
|
'bom_item__part',
|
||||||
'bom_item__sub_part',
|
'bom_item__sub_part',
|
||||||
|
'bom_item__sub_part__category',
|
||||||
'bom_item__sub_part__stock_items',
|
'bom_item__sub_part__stock_items',
|
||||||
'bom_item__sub_part__stock_items__allocations',
|
'bom_item__sub_part__stock_items__allocations',
|
||||||
'bom_item__sub_part__stock_items__sales_order_allocations',
|
'bom_item__sub_part__stock_items__sales_order_allocations',
|
||||||
|
|||||||
@@ -669,6 +669,7 @@ class SupplierPartManager(models.Manager):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPart(
|
class SupplierPart(
|
||||||
|
InvenTree.models.InvenTreeAttachmentMixin,
|
||||||
InvenTree.models.MetadataMixin,
|
InvenTree.models.MetadataMixin,
|
||||||
InvenTree.models.InvenTreeBarcodeMixin,
|
InvenTree.models.InvenTreeBarcodeMixin,
|
||||||
InvenTree.models.InvenTreeNotesMixin,
|
InvenTree.models.InvenTreeNotesMixin,
|
||||||
|
|||||||
@@ -229,3 +229,30 @@ ldap:
|
|||||||
#global_settings:
|
#global_settings:
|
||||||
# INVENTREE_DEFAULT_CURRENCY: 'CNY'
|
# INVENTREE_DEFAULT_CURRENCY: 'CNY'
|
||||||
# INVENTREE_RESTRICT_ABOUT: true
|
# INVENTREE_RESTRICT_ABOUT: true
|
||||||
|
|
||||||
|
# Storage configuration
|
||||||
|
# Ref: https://docs.inventree.org/en/stable/start/config/#storage-backends
|
||||||
|
storage:
|
||||||
|
target: local # s3, sftp
|
||||||
|
# s3:
|
||||||
|
# access_key: 'abc123-key'
|
||||||
|
# secret_key: 'abc123-secret'
|
||||||
|
# bucket_name: 'my-bucket'
|
||||||
|
# region_name: 'fsn1'
|
||||||
|
# endpoint_url: 'https://fsn1.your-objectstorage.com'
|
||||||
|
# location: 'inventree-server_subdir'
|
||||||
|
# default_acl: private
|
||||||
|
# verify_ssl: true
|
||||||
|
# overwrite: true
|
||||||
|
# virtual: true
|
||||||
|
# sftp:
|
||||||
|
# host: 'sftp://ftp-target.example.org:22'
|
||||||
|
# uid: 1000
|
||||||
|
# gid: 1000
|
||||||
|
# location: 'inventree-server_subdir'
|
||||||
|
# params:
|
||||||
|
# # See https://docs.paramiko.org/en/latest/api/client.html#paramiko.client.SSHClient.connect
|
||||||
|
# port: 22
|
||||||
|
# username: 'user'
|
||||||
|
# password: 'pwd'
|
||||||
|
# compress: False
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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
@@ -15,6 +15,7 @@ from rest_framework import serializers
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
import part.filters
|
import part.filters
|
||||||
|
import part.tasks as part_tasks
|
||||||
from data_exporter.mixins import DataExportViewMixin
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from InvenTree.api import (
|
from InvenTree.api import (
|
||||||
BulkCreateMixin,
|
BulkCreateMixin,
|
||||||
@@ -47,6 +48,7 @@ from InvenTree.mixins import (
|
|||||||
SerializerContextMixin,
|
SerializerContextMixin,
|
||||||
UpdateAPI,
|
UpdateAPI,
|
||||||
)
|
)
|
||||||
|
from InvenTree.tasks import offload_task
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
|
|
||||||
from . import serializers as part_serializers
|
from . import serializers as part_serializers
|
||||||
@@ -633,7 +635,14 @@ class PartValidateBOM(RetrieveUpdateAPI):
|
|||||||
|
|
||||||
valid = str2bool(serializer.validated_data.get('valid', False))
|
valid = str2bool(serializer.validated_data.get('valid', False))
|
||||||
|
|
||||||
part.validate_bom(request.user, valid=valid)
|
# BOM validation may take some time, so we offload it to a background task
|
||||||
|
offload_task(
|
||||||
|
part_tasks.validate_bom,
|
||||||
|
part.pk,
|
||||||
|
valid,
|
||||||
|
user_id=request.user.pk if request and request.user else None,
|
||||||
|
group='part',
|
||||||
|
)
|
||||||
|
|
||||||
# Re-serialize the response
|
# Re-serialize the response
|
||||||
serializer = self.get_serializer(part, many=False)
|
serializer = self.get_serializer(part, many=False)
|
||||||
@@ -1663,6 +1672,7 @@ class BomList(
|
|||||||
|
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'can_build',
|
'can_build',
|
||||||
|
'category',
|
||||||
'quantity',
|
'quantity',
|
||||||
'setup_quantity',
|
'setup_quantity',
|
||||||
'attrition',
|
'attrition',
|
||||||
@@ -1683,6 +1693,7 @@ class BomList(
|
|||||||
]
|
]
|
||||||
|
|
||||||
ordering_field_aliases = {
|
ordering_field_aliases = {
|
||||||
|
'category': 'sub_part__category__name',
|
||||||
'sub_part': 'sub_part__name',
|
'sub_part': 'sub_part__name',
|
||||||
'pricing_min': 'sub_part__pricing_data__overall_min',
|
'pricing_min': 'sub_part__pricing_data__overall_min',
|
||||||
'pricing_max': 'sub_part__pricing_data__overall_max',
|
'pricing_max': 'sub_part__pricing_data__overall_max',
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ class PartThumbSerializer(serializers.Serializer):
|
|||||||
Used to serve and display existing Part images.
|
Used to serve and display existing Part images.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
image = serializers.URLField(read_only=True)
|
image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True)
|
||||||
count = serializers.IntegerField(read_only=True)
|
count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -1668,13 +1668,11 @@ class BomItemSerializer(
|
|||||||
'rounding_multiple',
|
'rounding_multiple',
|
||||||
'note',
|
'note',
|
||||||
'pk',
|
'pk',
|
||||||
'part_detail',
|
|
||||||
'pricing_min',
|
'pricing_min',
|
||||||
'pricing_max',
|
'pricing_max',
|
||||||
'pricing_min_total',
|
'pricing_min_total',
|
||||||
'pricing_max_total',
|
'pricing_max_total',
|
||||||
'pricing_updated',
|
'pricing_updated',
|
||||||
'sub_part_detail',
|
|
||||||
'substitutes',
|
'substitutes',
|
||||||
'validated',
|
'validated',
|
||||||
# Annotated fields describing available quantity
|
# Annotated fields describing available quantity
|
||||||
@@ -1688,6 +1686,10 @@ class BomItemSerializer(
|
|||||||
'building',
|
'building',
|
||||||
# Annotate the total potential quantity we can build
|
# Annotate the total potential quantity we can build
|
||||||
'can_build',
|
'can_build',
|
||||||
|
# Optional detail fields
|
||||||
|
'part_detail',
|
||||||
|
'sub_part_detail',
|
||||||
|
'category_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
|
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
|
||||||
@@ -1744,6 +1746,17 @@ class BomItemSerializer(
|
|||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
category_detail = enable_filter(
|
||||||
|
CategorySerializer(
|
||||||
|
source='sub_part.category',
|
||||||
|
label=_('Category'),
|
||||||
|
many=False,
|
||||||
|
read_only=True,
|
||||||
|
allow_null=True,
|
||||||
|
),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
on_order = serializers.FloatField(
|
on_order = serializers.FloatField(
|
||||||
label=_('On Order'), read_only=True, allow_null=True
|
label=_('On Order'), read_only=True, allow_null=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Background task definitions for the 'part' app."""
|
"""Background task definitions for the 'part' app."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
@@ -437,3 +438,35 @@ def check_bom_valid(part_id: int):
|
|||||||
if valid != part.bom_validated:
|
if valid != part.bom_validated:
|
||||||
part.bom_validated = valid
|
part.bom_validated = valid
|
||||||
part.save()
|
part.save()
|
||||||
|
|
||||||
|
|
||||||
|
@tracer.start_as_current_span('validate_bom')
|
||||||
|
def validate_bom(part_id: int, valid: bool, user_id: Optional[int] = None):
|
||||||
|
"""Run BOM validation for the specified Part.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
part_id: The ID of the part for which to validate the BOM.
|
||||||
|
valid: Boolean indicating whether the BOM is valid or not.
|
||||||
|
user_id: Optional ID of the user performing the validation.
|
||||||
|
"""
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from part.models import Part
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
try:
|
||||||
|
part = Part.objects.get(pk=part_id)
|
||||||
|
except Part.DoesNotExist:
|
||||||
|
logger.warning('validate_bom: Part with ID %s does not exist', part_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
user = None
|
||||||
|
else:
|
||||||
|
user = None
|
||||||
|
|
||||||
|
part.validate_bom(user, valid=valid)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ logger = structlog.get_logger('inventree')
|
|||||||
|
|
||||||
# List of supported feature types
|
# List of supported feature types
|
||||||
FeatureType = Literal[
|
FeatureType = Literal[
|
||||||
|
'spotlight_action', # Custom actions for the spotlight search
|
||||||
'dashboard', # Custom dashboard items
|
'dashboard', # Custom dashboard items
|
||||||
'panel', # Custom panels
|
'panel', # Custom panels
|
||||||
'template_editor', # Custom template editor
|
'template_editor', # Custom template editor
|
||||||
@@ -99,11 +100,12 @@ class UserInterfaceMixin:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
feature_map = {
|
feature_map = {
|
||||||
|
'spotlight_action': self.get_ui_spotlight_actions,
|
||||||
'dashboard': self.get_ui_dashboard_items,
|
'dashboard': self.get_ui_dashboard_items,
|
||||||
|
'navigation': self.get_ui_navigation_items,
|
||||||
'panel': self.get_ui_panels,
|
'panel': self.get_ui_panels,
|
||||||
'template_editor': self.get_ui_template_editors,
|
'template_editor': self.get_ui_template_editors,
|
||||||
'template_preview': self.get_ui_template_previews,
|
'template_preview': self.get_ui_template_previews,
|
||||||
'navigation': self.get_ui_navigation_items,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if feature_type in feature_map:
|
if feature_type in feature_map:
|
||||||
@@ -112,6 +114,21 @@ class UserInterfaceMixin:
|
|||||||
logger.warning(f'Invalid feature type: {feature_type}')
|
logger.warning(f'Invalid feature type: {feature_type}')
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_ui_spotlight_actions(
|
||||||
|
self, request: Request, context: dict, **kwargs
|
||||||
|
) -> list[UIFeature]:
|
||||||
|
"""Return a list of custom actions to be injected into the UI spotlight.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTPRequest object (including user information)
|
||||||
|
context: Additional context data provided by the UI (query parameters)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of custom actions to be injected into the UI spotlight.
|
||||||
|
"""
|
||||||
|
# Default implementation returns an empty list
|
||||||
|
return []
|
||||||
|
|
||||||
def get_ui_panels(
|
def get_ui_panels(
|
||||||
self, request: Request, context: dict, **kwargs
|
self, request: Request, context: dict, **kwargs
|
||||||
) -> list[UIFeature]:
|
) -> list[UIFeature]:
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_ui_spotlight_actions(self, request, context, **kwargs):
|
||||||
|
"""Return a list of custom actions to be injected into the UI spotlight."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'key': 'sample-action',
|
||||||
|
'title': 'Sample Action',
|
||||||
|
'description': 'This is a sample action for the spotlight search',
|
||||||
|
'icon': 'ti:search:outline',
|
||||||
|
'source': self.plugin_static_file(
|
||||||
|
'sample_action.js:performSampleAction'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
def get_ui_panels(self, request, context, **kwargs):
|
def get_ui_panels(self, request, context, **kwargs):
|
||||||
"""Return a list of custom panels to be injected into the UI."""
|
"""Return a list of custom panels to be injected into the UI."""
|
||||||
panels = []
|
panels = []
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* A sample action plugin for InvenTree.
|
||||||
|
*
|
||||||
|
* This is a very basic example of how to define a custom action.
|
||||||
|
* In practice, you would want to implement more complex logic here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function performSampleAction(data) {
|
||||||
|
// Simply log the data to the console
|
||||||
|
alert("Sample! Refer to the console");
|
||||||
|
console.log("Sample action performed with data:", data);
|
||||||
|
}
|
||||||
@@ -15,8 +15,12 @@ from django.db.models.query import QuerySet
|
|||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
|
from djmoney.money import Money
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
import common.currency
|
||||||
import common.icons
|
import common.icons
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.helpers_model
|
import InvenTree.helpers_model
|
||||||
@@ -451,7 +455,17 @@ def cast_to_type(value: Any, cast: type) -> Any:
|
|||||||
|
|
||||||
def debug_vars(x: Any, y: Any) -> str:
|
def debug_vars(x: Any, y: Any) -> str:
|
||||||
"""Return a debug string showing the types and values of two variables."""
|
"""Return a debug string showing the types and values of two variables."""
|
||||||
return f": x='{x}' ({type(x).__name__}), y='{y}' ({type(y).__name__})"
|
return f"x='{x}' ({type(x).__name__}), y='{y}' ({type(y).__name__})"
|
||||||
|
|
||||||
|
|
||||||
|
def check_nulls(func: str, *arg):
|
||||||
|
"""Check if any of the provided arguments is null.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any argument is None
|
||||||
|
"""
|
||||||
|
if any(a is None for a in arg):
|
||||||
|
raise ValidationError(f'{func}: {_("Null value provided to function")}')
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
@@ -466,11 +480,13 @@ def add(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be added together
|
ValidationError: If the values cannot be added together
|
||||||
"""
|
"""
|
||||||
|
check_nulls('add', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) + make_decimal(y)
|
result = make_decimal(x) + make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot add values of incompatible types') + debug_vars(x, y)
|
f'add: {_("Cannot add values of incompatible types")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
|
|
||||||
@@ -487,11 +503,13 @@ def subtract(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be subtracted
|
ValidationError: If the values cannot be subtracted
|
||||||
"""
|
"""
|
||||||
|
check_nulls('subtract', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) - make_decimal(y)
|
result = make_decimal(x) - make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot subtract values of incompatible types') + debug_vars(x, y)
|
f'subtract: {_("Cannot subtract values of incompatible types")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
@@ -509,11 +527,13 @@ def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be multiplied together
|
ValidationError: If the values cannot be multiplied together
|
||||||
"""
|
"""
|
||||||
|
check_nulls('multiply', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) * make_decimal(y)
|
result = make_decimal(x) * make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot multiply values of incompatible types') + debug_vars(x, y)
|
f'multiply: {_("Cannot multiply values of incompatible types")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
@@ -531,14 +551,18 @@ def divide(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be divided
|
ValidationError: If the values cannot be divided
|
||||||
"""
|
"""
|
||||||
|
check_nulls('divide', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) / make_decimal(y)
|
result = make_decimal(x) / make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot divide values of incompatible types') + debug_vars(x, y)
|
f'divide: {_("Cannot divide values of incompatible types")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
except ZeroDivisionError:
|
except ZeroDivisionError:
|
||||||
raise ValidationError(_('Cannot divide by zero') + debug_vars(x, y))
|
raise ValidationError(
|
||||||
|
f'divide: {_("Cannot divide by zero")}: {debug_vars(x, y)}'
|
||||||
|
)
|
||||||
|
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
|
|
||||||
@@ -555,16 +579,17 @@ def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be used in a modulo operation
|
ValidationError: If the values cannot be used in a modulo operation
|
||||||
"""
|
"""
|
||||||
|
check_nulls('modulo', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) % make_decimal(y)
|
result = make_decimal(x) % make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot perform modulo operation with values of incompatible types')
|
f'modulo: {_("Cannot perform modulo operation with values of incompatible types")} {debug_vars(x, y)}'
|
||||||
+ debug_vars(x, y)
|
|
||||||
)
|
)
|
||||||
except ZeroDivisionError:
|
except ZeroDivisionError:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot perform modulo operation with divisor of zero') + debug_vars(x, y)
|
f'modulo: {_("Cannot perform modulo operation with divisor of zero")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
@@ -576,6 +601,70 @@ def render_currency(money, **kwargs):
|
|||||||
return InvenTree.helpers_model.render_currency(money, **kwargs)
|
return InvenTree.helpers_model.render_currency(money, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def create_currency(
|
||||||
|
amount: Union[str, int, float, Decimal], currency: Optional[str] = None, **kwargs
|
||||||
|
):
|
||||||
|
"""Create a Money object, with the provided amount and currency.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
amount: The numeric amount (a numeric type or string)
|
||||||
|
currency: The currency code (e.g. 'USD', 'EUR', etc.)
|
||||||
|
|
||||||
|
Note: If the currency is not provided, the default system currency will be used.
|
||||||
|
"""
|
||||||
|
check_nulls('create_currency', amount)
|
||||||
|
|
||||||
|
currency = currency or common.currency.currency_code_default()
|
||||||
|
currency = currency.strip().upper()
|
||||||
|
|
||||||
|
if currency not in common.currency.CURRENCIES:
|
||||||
|
raise ValidationError(
|
||||||
|
f'create_currency: {_("Invalid currency code")}: {currency}'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
money = Money(amount, currency)
|
||||||
|
except InvalidOperation:
|
||||||
|
raise ValidationError(f'create_currency: {_("Invalid amount")}: {amount}')
|
||||||
|
|
||||||
|
return money
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def convert_currency(money: Money, currency: Optional[str] = None, **kwargs):
|
||||||
|
"""Convert a Money object to the specified currency.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
money: The Money instance to be converted
|
||||||
|
currency: The target currency code (e.g. 'USD', 'EUR', etc.)
|
||||||
|
|
||||||
|
Note: If the currency is not provided, the default system currency will be used.
|
||||||
|
"""
|
||||||
|
check_nulls('convert_currency', money)
|
||||||
|
|
||||||
|
if not isinstance(money, Money):
|
||||||
|
raise TypeError('convert_currency tag requires a Money instance')
|
||||||
|
|
||||||
|
currency = currency or common.currency.currency_code_default()
|
||||||
|
currency = currency.strip().upper()
|
||||||
|
|
||||||
|
if currency not in common.currency.CURRENCIES:
|
||||||
|
raise ValidationError(
|
||||||
|
f'convert_currency: {_("Invalid currency code")}: {currency}'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
converted = convert_money(money, currency)
|
||||||
|
except MissingRate:
|
||||||
|
# Re-throw error with more context
|
||||||
|
raise ValidationError(
|
||||||
|
f'convert_currency: {_("Missing exchange rate")} {money.currency} -> {currency}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return converted
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def render_html_text(text: str, **kwargs):
|
def render_html_text(text: str, **kwargs):
|
||||||
"""Render a text item with some simple html tags.
|
"""Render a text item with some simple html tags.
|
||||||
@@ -607,6 +696,7 @@ def render_html_text(text: str, **kwargs):
|
|||||||
def format_number(
|
def format_number(
|
||||||
number: Union[int, float, Decimal],
|
number: Union[int, float, Decimal],
|
||||||
decimal_places: Optional[int] = None,
|
decimal_places: Optional[int] = None,
|
||||||
|
multiplier: Optional[Union[int, float, Decimal]] = None,
|
||||||
integer: bool = False,
|
integer: bool = False,
|
||||||
leading: int = 0,
|
leading: int = 0,
|
||||||
separator: Optional[str] = None,
|
separator: Optional[str] = None,
|
||||||
@@ -616,16 +706,22 @@ def format_number(
|
|||||||
Arguments:
|
Arguments:
|
||||||
number: The number to be formatted
|
number: The number to be formatted
|
||||||
decimal_places: Number of decimal places to render
|
decimal_places: Number of decimal places to render
|
||||||
|
multiplier: Optional multiplier to apply to the number before formatting
|
||||||
integer: Boolean, whether to render the number as an integer
|
integer: Boolean, whether to render the number as an integer
|
||||||
leading: Number of leading zeros (default = 0)
|
leading: Number of leading zeros (default = 0)
|
||||||
separator: Character to use as a thousands separator (default = None)
|
separator: Character to use as a thousands separator (default = None)
|
||||||
"""
|
"""
|
||||||
|
check_nulls('format_number', number)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
number = Decimal(str(number))
|
number = Decimal(str(number).strip())
|
||||||
except Exception:
|
except Exception:
|
||||||
# If the number cannot be converted to a Decimal, just return the original value
|
# If the number cannot be converted to a Decimal, just return the original value
|
||||||
return str(number)
|
return str(number)
|
||||||
|
|
||||||
|
if multiplier is not None:
|
||||||
|
number *= Decimal(str(multiplier).strip())
|
||||||
|
|
||||||
if integer:
|
if integer:
|
||||||
# Convert to integer
|
# Convert to integer
|
||||||
number = Decimal(int(number))
|
number = Decimal(int(number))
|
||||||
@@ -641,7 +737,13 @@ def format_number(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Re-encode, and normalize again
|
# Re-encode, and normalize again
|
||||||
value = Decimal(number).normalize()
|
# Ensure that the output never uses scientific notation
|
||||||
|
value = Decimal(number)
|
||||||
|
value = (
|
||||||
|
value.quantize(Decimal(1))
|
||||||
|
if value == value.to_integral()
|
||||||
|
else value.normalize()
|
||||||
|
)
|
||||||
|
|
||||||
if separator:
|
if separator:
|
||||||
value = f'{value:,}'
|
value = f'{value:,}'
|
||||||
@@ -670,6 +772,8 @@ def format_datetime(
|
|||||||
timezone: The timezone to use for the date (defaults to the server timezone)
|
timezone: The timezone to use for the date (defaults to the server timezone)
|
||||||
fmt: The format string to use (defaults to ISO formatting)
|
fmt: The format string to use (defaults to ISO formatting)
|
||||||
"""
|
"""
|
||||||
|
check_nulls('format_datetime', dt)
|
||||||
|
|
||||||
dt = InvenTree.helpers.to_local_time(dt, timezone)
|
dt = InvenTree.helpers.to_local_time(dt, timezone)
|
||||||
|
|
||||||
if fmt:
|
if fmt:
|
||||||
@@ -687,6 +791,8 @@ def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = N
|
|||||||
timezone: The timezone to use for the date (defaults to the server timezone)
|
timezone: The timezone to use for the date (defaults to the server timezone)
|
||||||
fmt: The format string to use (defaults to ISO formatting)
|
fmt: The format string to use (defaults to ISO formatting)
|
||||||
"""
|
"""
|
||||||
|
check_nulls('format_date', dt)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dt = InvenTree.helpers.to_local_time(dt, timezone).date()
|
dt = InvenTree.helpers.to_local_time(dt, timezone).date()
|
||||||
except TypeError:
|
except TypeError:
|
||||||
|
|||||||
@@ -270,7 +270,19 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
"""Simple tests for number formatting tags."""
|
"""Simple tests for number formatting tags."""
|
||||||
fn = report_tags.format_number
|
fn = report_tags.format_number
|
||||||
|
|
||||||
|
# Passing None should raise an error
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
fn(None)
|
||||||
|
|
||||||
|
for i in [1, '1', '1.0000', ' 1 ']:
|
||||||
|
self.assertEqual(fn(i), '1')
|
||||||
|
|
||||||
|
for x in ['10.000000', ' 10 ', 10.000000, 10]:
|
||||||
|
self.assertEqual(fn(x), '10')
|
||||||
|
|
||||||
self.assertEqual(fn(1234), '1234')
|
self.assertEqual(fn(1234), '1234')
|
||||||
|
self.assertEqual(fn(1234.5678, decimal_places=0), '1235')
|
||||||
|
self.assertEqual(fn(1234.5678, decimal_places=1), '1234.6')
|
||||||
self.assertEqual(fn(1234.5678, decimal_places=2), '1234.57')
|
self.assertEqual(fn(1234.5678, decimal_places=2), '1234.57')
|
||||||
self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
|
self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
|
||||||
self.assertEqual(fn(-9999.5678, decimal_places=2, separator=','), '-9,999.57')
|
self.assertEqual(fn(-9999.5678, decimal_places=2, separator=','), '-9,999.57')
|
||||||
@@ -278,6 +290,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
fn(9988776655.4321, integer=True, separator=' '), '9 988 776 655'
|
fn(9988776655.4321, integer=True, separator=' '), '9 988 776 655'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Test with multiplier
|
||||||
|
self.assertEqual(fn(1000, multiplier=1.5), '1500')
|
||||||
|
|
||||||
# Failure cases
|
# Failure cases
|
||||||
self.assertEqual(fn('abc'), 'abc')
|
self.assertEqual(fn('abc'), 'abc')
|
||||||
self.assertEqual(fn(1234.456, decimal_places='a'), '1234.456')
|
self.assertEqual(fn(1234.456, decimal_places='a'), '1234.456')
|
||||||
@@ -438,6 +453,55 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
self.assertEqual(report_tags.render_currency(m, min_decimal_places='a'), exp_m)
|
self.assertEqual(report_tags.render_currency(m, min_decimal_places='a'), exp_m)
|
||||||
self.assertEqual(report_tags.render_currency(m, max_decimal_places='a'), exp_m)
|
self.assertEqual(report_tags.render_currency(m, max_decimal_places='a'), exp_m)
|
||||||
|
|
||||||
|
def test_create_currency(self):
|
||||||
|
"""Test the create_currency template tag."""
|
||||||
|
m = report_tags.create_currency(1000, 'USD')
|
||||||
|
self.assertIsInstance(m, Money)
|
||||||
|
self.assertEqual(m.amount, Decimal('1000'))
|
||||||
|
self.assertEqual(str(m.currency), 'USD')
|
||||||
|
|
||||||
|
# Test with invalid currency code
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
report_tags.create_currency(1000, 'QWERTY')
|
||||||
|
|
||||||
|
# Test with invalid amount
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
report_tags.create_currency('abc', 'USD')
|
||||||
|
|
||||||
|
def test_convert_currency(self):
|
||||||
|
"""Test the convert_currency template tag."""
|
||||||
|
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||||
|
|
||||||
|
# Generate some dummy exchange rates
|
||||||
|
rates = {'AUD': 1.5, 'CAD': 1.7, 'GBP': 0.9, 'USD': 1.0}
|
||||||
|
|
||||||
|
# Create a dummy backend
|
||||||
|
ExchangeBackend.objects.create(name='InvenTreeExchange', base_currency='USD')
|
||||||
|
|
||||||
|
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||||
|
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for currency, rate in rates.items():
|
||||||
|
items.append(Rate(currency=currency, value=rate, backend=backend))
|
||||||
|
|
||||||
|
Rate.objects.bulk_create(items)
|
||||||
|
|
||||||
|
m = report_tags.create_currency(1000, 'GBP')
|
||||||
|
|
||||||
|
# Test with valid conversion
|
||||||
|
converted = report_tags.convert_currency(m, 'CAD')
|
||||||
|
self.assertIsInstance(converted, Money)
|
||||||
|
self.assertEqual(str(converted.currency), 'CAD')
|
||||||
|
|
||||||
|
# Test with invalid currency code
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
report_tags.convert_currency(m, 'QWERTY')
|
||||||
|
|
||||||
|
# Test with missing exchange rate
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
report_tags.convert_currency(m, 'AFD')
|
||||||
|
|
||||||
def test_render_html_text(self):
|
def test_render_html_text(self):
|
||||||
"""Test the render_html_text template tag."""
|
"""Test the render_html_text template tag."""
|
||||||
# Test with a valid text
|
# Test with a valid text
|
||||||
|
|||||||
@@ -1290,10 +1290,14 @@ class StockList(
|
|||||||
search_fields = [
|
search_fields = [
|
||||||
'serial',
|
'serial',
|
||||||
'batch',
|
'batch',
|
||||||
|
'location__name',
|
||||||
'part__name',
|
'part__name',
|
||||||
'part__IPN',
|
'part__IPN',
|
||||||
'part__description',
|
'part__description',
|
||||||
'location__name',
|
'supplier_part__SKU',
|
||||||
|
'supplier_part__supplier__name',
|
||||||
|
'supplier_part__manufacturer_part__MPN',
|
||||||
|
'supplier_part__manufacturer_part__manufacturer__name',
|
||||||
'tags__name',
|
'tags__name',
|
||||||
'tags__slug',
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Unit tests for the 'users' app."""
|
"""Unit tests for the 'users' app."""
|
||||||
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@@ -351,20 +349,13 @@ class MFALoginTest(InvenTreeAPITestCase):
|
|||||||
response = self.post(login_url, auth_data, expected_code=200)
|
response = self.post(login_url, auth_data, expected_code=200)
|
||||||
self._helper_meta_val(response)
|
self._helper_meta_val(response)
|
||||||
|
|
||||||
|
return # TODO @matmair re-enable MFA tests once stable
|
||||||
# Add MFA - trying in a limited loop in case of timing issues
|
# Add MFA - trying in a limited loop in case of timing issues
|
||||||
success: bool = False
|
|
||||||
for _ in range(10):
|
|
||||||
try:
|
|
||||||
response = self.post(
|
response = self.post(
|
||||||
reverse('browser:mfa:manage_totp'),
|
reverse('browser:mfa:manage_totp'),
|
||||||
{'code': self.get_topt()},
|
{'code': self.get_topt()},
|
||||||
expected_code=200,
|
expected_code=200,
|
||||||
)
|
)
|
||||||
success = True
|
|
||||||
break
|
|
||||||
except AssertionError:
|
|
||||||
sleep(0.8)
|
|
||||||
self.assertTrue(success, 'Failed to add MFA device')
|
|
||||||
|
|
||||||
# There must be a TOTP device now - success
|
# There must be a TOTP device now - success
|
||||||
self.get(reverse('browser:mfa:manage_totp'), expected_code=200)
|
self.get(reverse('browser:mfa:manage_totp'), expected_code=200)
|
||||||
|
|||||||
@@ -382,9 +382,9 @@ distlib==0.4.0 \
|
|||||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||||
# via virtualenv
|
# via virtualenv
|
||||||
django==4.2.25 \
|
django==4.2.26 \
|
||||||
--hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
|
--hash=sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a \
|
||||||
--hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
|
--hash=sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280
|
||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
# django-slowtests
|
# django-slowtests
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Please keep this list sorted - if you pin a version provide a reason
|
# Please keep this list sorted - if you pin a version provide a reason
|
||||||
Django<5.0 # Django package
|
Django<6.0 # Django package
|
||||||
blessed # CLI for Q Monitor
|
blessed # CLI for Q Monitor
|
||||||
cryptography>=44.0.0 # Core cryptographic functionality
|
cryptography>=44.0.0 # Core cryptographic functionality
|
||||||
django-anymail[amazon_ses,postal] # Email backend for various providers
|
django-anymail[amazon_ses,postal] # Email backend for various providers
|
||||||
@@ -26,6 +26,7 @@ django-sql-utils # Advanced query annotation / aggregatio
|
|||||||
django-sslserver # Secure HTTP development server
|
django-sslserver # Secure HTTP development server
|
||||||
django-structlog # Structured logging
|
django-structlog # Structured logging
|
||||||
django-stdimage # Advanced ImageField management
|
django-stdimage # Advanced ImageField management
|
||||||
|
django-storages[s3,sftp] # Storage backends for Django
|
||||||
django-taggit # Tagging support
|
django-taggit # Tagging support
|
||||||
django-otp==1.3.0 # Two-factor authentication (legacy to ensure migrations) https://github.com/inventree/InvenTree/pull/6293
|
django-otp==1.3.0 # Two-factor authentication (legacy to ensure migrations) https://github.com/inventree/InvenTree/pull/6293
|
||||||
django-oauth-toolkit # OAuth2 provider
|
django-oauth-toolkit # OAuth2 provider
|
||||||
|
|||||||
+109
-10
@@ -22,6 +22,59 @@ babel==2.17.0 \
|
|||||||
--hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \
|
--hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \
|
||||||
--hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2
|
--hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2
|
||||||
# via py-moneyed
|
# via py-moneyed
|
||||||
|
bcrypt==4.3.0 \
|
||||||
|
--hash=sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f \
|
||||||
|
--hash=sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d \
|
||||||
|
--hash=sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24 \
|
||||||
|
--hash=sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3 \
|
||||||
|
--hash=sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c \
|
||||||
|
--hash=sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d \
|
||||||
|
--hash=sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd \
|
||||||
|
--hash=sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f \
|
||||||
|
--hash=sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f \
|
||||||
|
--hash=sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d \
|
||||||
|
--hash=sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe \
|
||||||
|
--hash=sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231 \
|
||||||
|
--hash=sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef \
|
||||||
|
--hash=sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18 \
|
||||||
|
--hash=sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f \
|
||||||
|
--hash=sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e \
|
||||||
|
--hash=sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732 \
|
||||||
|
--hash=sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304 \
|
||||||
|
--hash=sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0 \
|
||||||
|
--hash=sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8 \
|
||||||
|
--hash=sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938 \
|
||||||
|
--hash=sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62 \
|
||||||
|
--hash=sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180 \
|
||||||
|
--hash=sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af \
|
||||||
|
--hash=sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669 \
|
||||||
|
--hash=sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761 \
|
||||||
|
--hash=sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51 \
|
||||||
|
--hash=sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23 \
|
||||||
|
--hash=sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09 \
|
||||||
|
--hash=sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505 \
|
||||||
|
--hash=sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4 \
|
||||||
|
--hash=sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753 \
|
||||||
|
--hash=sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59 \
|
||||||
|
--hash=sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b \
|
||||||
|
--hash=sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d \
|
||||||
|
--hash=sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a \
|
||||||
|
--hash=sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b \
|
||||||
|
--hash=sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a \
|
||||||
|
--hash=sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90 \
|
||||||
|
--hash=sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492 \
|
||||||
|
--hash=sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce \
|
||||||
|
--hash=sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb \
|
||||||
|
--hash=sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb \
|
||||||
|
--hash=sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1 \
|
||||||
|
--hash=sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676 \
|
||||||
|
--hash=sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b \
|
||||||
|
--hash=sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe \
|
||||||
|
--hash=sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281 \
|
||||||
|
--hash=sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1 \
|
||||||
|
--hash=sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef \
|
||||||
|
--hash=sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d
|
||||||
|
# via paramiko
|
||||||
bleach[css]==6.2.0 \
|
bleach[css]==6.2.0 \
|
||||||
--hash=sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e \
|
--hash=sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e \
|
||||||
--hash=sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f
|
--hash=sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f
|
||||||
@@ -33,7 +86,9 @@ blessed==1.22.0 \
|
|||||||
boto3==1.40.55 \
|
boto3==1.40.55 \
|
||||||
--hash=sha256:27e35b4fa9edd414ce06c1a748bf57cacd8203271847d93fc1053e4a4ec6e1a9 \
|
--hash=sha256:27e35b4fa9edd414ce06c1a748bf57cacd8203271847d93fc1053e4a4ec6e1a9 \
|
||||||
--hash=sha256:2e30f5a0d49e107b8a5c0c487891afd300bfa410e1d918bf187ae45ac3839332
|
--hash=sha256:2e30f5a0d49e107b8a5c0c487891afd300bfa410e1d918bf187ae45ac3839332
|
||||||
# via django-anymail
|
# via
|
||||||
|
# django-anymail
|
||||||
|
# django-storages
|
||||||
botocore==1.40.55 \
|
botocore==1.40.55 \
|
||||||
--hash=sha256:79b6472e2de92b3519d44fc1eec8c5feced7f99a0d10fdea6dc93133426057c1 \
|
--hash=sha256:79b6472e2de92b3519d44fc1eec8c5feced7f99a0d10fdea6dc93133426057c1 \
|
||||||
--hash=sha256:cdc38f7a4ddb30a2cd1cdd4fabde2a5a16e41b5a642292e1c30de5c4e46f5d44
|
--hash=sha256:cdc38f7a4ddb30a2cd1cdd4fabde2a5a16e41b5a642292e1c30de5c4e46f5d44
|
||||||
@@ -260,6 +315,7 @@ cffi==2.0.0 \
|
|||||||
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
|
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
|
||||||
# via
|
# via
|
||||||
# cryptography
|
# cryptography
|
||||||
|
# pynacl
|
||||||
# weasyprint
|
# weasyprint
|
||||||
charset-normalizer==3.4.4 \
|
charset-normalizer==3.4.4 \
|
||||||
--hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \
|
--hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \
|
||||||
@@ -420,6 +476,7 @@ cryptography==44.0.3 \
|
|||||||
# djangorestframework-simplejwt
|
# djangorestframework-simplejwt
|
||||||
# fido2
|
# fido2
|
||||||
# jwcrypto
|
# jwcrypto
|
||||||
|
# paramiko
|
||||||
# pyjwt
|
# pyjwt
|
||||||
cssselect2==0.8.0 \
|
cssselect2==0.8.0 \
|
||||||
--hash=sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e \
|
--hash=sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e \
|
||||||
@@ -429,9 +486,9 @@ defusedxml==0.7.1 \
|
|||||||
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
|
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
|
||||||
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
||||||
# via python3-openid
|
# via python3-openid
|
||||||
django==4.2.25 \
|
django==4.2.26 \
|
||||||
--hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
|
--hash=sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a \
|
||||||
--hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
|
--hash=sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280
|
||||||
# via
|
# via
|
||||||
# -r src/backend/requirements.in
|
# -r src/backend/requirements.in
|
||||||
# django-allauth
|
# django-allauth
|
||||||
@@ -455,6 +512,7 @@ django==4.2.25 \
|
|||||||
# django-sql-utils
|
# django-sql-utils
|
||||||
# django-sslserver
|
# django-sslserver
|
||||||
# django-stdimage
|
# django-stdimage
|
||||||
|
# django-storages
|
||||||
# django-structlog
|
# django-structlog
|
||||||
# django-taggit
|
# django-taggit
|
||||||
# django-xforwardedfor-middleware
|
# django-xforwardedfor-middleware
|
||||||
@@ -565,6 +623,10 @@ django-stdimage==6.0.2 \
|
|||||||
--hash=sha256:880ab14828be56b53f711c3afae83c219ddd5d9af00850626736feb48382bf7f \
|
--hash=sha256:880ab14828be56b53f711c3afae83c219ddd5d9af00850626736feb48382bf7f \
|
||||||
--hash=sha256:9a73f7da48c48074580e2b032d5bdb7164935dbe4b9dc4fb88a7e112f3d521c8
|
--hash=sha256:9a73f7da48c48074580e2b032d5bdb7164935dbe4b9dc4fb88a7e112f3d521c8
|
||||||
# via -r src/backend/requirements.in
|
# via -r src/backend/requirements.in
|
||||||
|
django-storages[s3, sftp]==1.14.6 \
|
||||||
|
--hash=sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9 \
|
||||||
|
--hash=sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9
|
||||||
|
# via -r src/backend/requirements.in
|
||||||
django-structlog==9.1.1 \
|
django-structlog==9.1.1 \
|
||||||
--hash=sha256:14342c6c824581f1e063c88a8bc52314cd67995a3bd4a4fc8c27ea37ccd78947 \
|
--hash=sha256:14342c6c824581f1e063c88a8bc52314cd67995a3bd4a4fc8c27ea37ccd78947 \
|
||||||
--hash=sha256:5b6ac3abdf6549e94ccb35160b1f10266f1627c3ac77844571235a08a1ddae66
|
--hash=sha256:5b6ac3abdf6549e94ccb35160b1f10266f1627c3ac77844571235a08a1ddae66
|
||||||
@@ -798,6 +860,10 @@ inflection==0.5.1 \
|
|||||||
--hash=sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417 \
|
--hash=sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417 \
|
||||||
--hash=sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2
|
--hash=sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2
|
||||||
# via drf-spectacular
|
# via drf-spectacular
|
||||||
|
invoke==2.2.1 \
|
||||||
|
--hash=sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8 \
|
||||||
|
--hash=sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707
|
||||||
|
# via paramiko
|
||||||
isodate==0.7.2 \
|
isodate==0.7.2 \
|
||||||
--hash=sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15 \
|
--hash=sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15 \
|
||||||
--hash=sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6
|
--hash=sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6
|
||||||
@@ -1202,6 +1268,10 @@ packaging==25.0 \
|
|||||||
# via
|
# via
|
||||||
# gunicorn
|
# gunicorn
|
||||||
# opentelemetry-instrumentation
|
# opentelemetry-instrumentation
|
||||||
|
paramiko==4.0.0 \
|
||||||
|
--hash=sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9 \
|
||||||
|
--hash=sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f
|
||||||
|
# via django-storages
|
||||||
pdf2image==1.17.0 \
|
pdf2image==1.17.0 \
|
||||||
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
|
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
|
||||||
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
|
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
|
||||||
@@ -1383,6 +1453,35 @@ pyjwt[crypto]==2.10.1 \
|
|||||||
# via
|
# via
|
||||||
# django-allauth
|
# django-allauth
|
||||||
# djangorestframework-simplejwt
|
# djangorestframework-simplejwt
|
||||||
|
pynacl==1.6.0 \
|
||||||
|
--hash=sha256:04f20784083014e265ad58c1b2dd562c3e35864b5394a14ab54f5d150ee9e53e \
|
||||||
|
--hash=sha256:10d755cf2a455d8c0f8c767a43d68f24d163b8fe93ccfaabfa7bafd26be58d73 \
|
||||||
|
--hash=sha256:140373378e34a1f6977e573033d1dd1de88d2a5d90ec6958c9485b2fd9f3eb90 \
|
||||||
|
--hash=sha256:16c60daceee88d04f8d41d0a4004a7ed8d9a5126b997efd2933e08e93a3bd850 \
|
||||||
|
--hash=sha256:16dd347cdc8ae0b0f6187a2608c0af1c8b7ecbbe6b4a06bff8253c192f696990 \
|
||||||
|
--hash=sha256:25720bad35dfac34a2bcdd61d9e08d6bfc6041bebc7751d9c9f2446cf1e77d64 \
|
||||||
|
--hash=sha256:2d6cd56ce4998cb66a6c112fda7b1fdce5266c9f05044fa72972613bef376d15 \
|
||||||
|
--hash=sha256:347dcddce0b4d83ed3f32fd00379c83c425abee5a9d2cd0a2c84871334eaff64 \
|
||||||
|
--hash=sha256:4853c154dc16ea12f8f3ee4b7e763331876316cc3a9f06aeedf39bcdca8f9995 \
|
||||||
|
--hash=sha256:49c336dd80ea54780bcff6a03ee1a476be1612423010472e60af83452aa0f442 \
|
||||||
|
--hash=sha256:4a25cfede801f01e54179b8ff9514bd7b5944da560b7040939732d1804d25419 \
|
||||||
|
--hash=sha256:51fed9fe1bec9e7ff9af31cd0abba179d0e984a2960c77e8e5292c7e9b7f7b5d \
|
||||||
|
--hash=sha256:536703b8f90e911294831a7fbcd0c062b837f3ccaa923d92a6254e11178aaf42 \
|
||||||
|
--hash=sha256:5789f016e08e5606803161ba24de01b5a345d24590a80323379fc4408832d290 \
|
||||||
|
--hash=sha256:6b08eab48c9669d515a344fb0ef27e2cbde847721e34bba94a343baa0f33f1f4 \
|
||||||
|
--hash=sha256:6b393bc5e5a0eb86bb85b533deb2d2c815666665f840a09e0aa3362bb6088736 \
|
||||||
|
--hash=sha256:84709cea8f888e618c21ed9a0efdb1a59cc63141c403db8bf56c469b71ad56f2 \
|
||||||
|
--hash=sha256:8bfaa0a28a1ab718bad6239979a5a57a8d1506d0caf2fba17e524dbb409441cf \
|
||||||
|
--hash=sha256:bbcc4452a1eb10cd5217318c822fde4be279c9de8567f78bad24c773c21254f8 \
|
||||||
|
--hash=sha256:cb36deafe6e2bce3b286e5d1f3e1c246e0ccdb8808ddb4550bb2792f2df298f2 \
|
||||||
|
--hash=sha256:cf831615cc16ba324240de79d925eacae8265b7691412ac6b24221db157f6bd1 \
|
||||||
|
--hash=sha256:dcdeb41c22ff3c66eef5e63049abf7639e0db4edee57ba70531fc1b6b133185d \
|
||||||
|
--hash=sha256:dea103a1afcbc333bc0e992e64233d360d393d1e63d0bc88554f572365664348 \
|
||||||
|
--hash=sha256:ef214b90556bb46a485b7da8258e59204c244b1b5b576fb71848819b468c44a7 \
|
||||||
|
--hash=sha256:f3482abf0f9815e7246d461fab597aa179b7524628a4bc36f86a7dc418d2608d \
|
||||||
|
--hash=sha256:f46386c24a65383a9081d68e9c2de909b1834ec74ff3013271f1bca9c2d233eb \
|
||||||
|
--hash=sha256:f4b3824920e206b4f52abd7de621ea7a44fd3cb5c8daceb7c3612345dfc54f2e
|
||||||
|
# via paramiko
|
||||||
pypdf==6.1.3 \
|
pypdf==6.1.3 \
|
||||||
--hash=sha256:8d420d1e79dc1743f31a57707cabb6dcd5b17e8b9a302af64b30202c5700ab9d \
|
--hash=sha256:8d420d1e79dc1743f31a57707cabb6dcd5b17e8b9a302af64b30202c5700ab9d \
|
||||||
--hash=sha256:eb049195e46f014fc155f566fa20e09d70d4646a9891164ac25fa0cbcfcdbcb5
|
--hash=sha256:eb049195e46f014fc155f566fa20e09d70d4646a9891164ac25fa0cbcfcdbcb5
|
||||||
@@ -1402,9 +1501,9 @@ python-dateutil==2.9.0.post0 \
|
|||||||
# botocore
|
# botocore
|
||||||
# django-recurrence
|
# django-recurrence
|
||||||
# icalendar
|
# icalendar
|
||||||
python-dotenv==1.1.1 \
|
python-dotenv==1.2.1 \
|
||||||
--hash=sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc \
|
--hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \
|
||||||
--hash=sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab
|
--hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
|
||||||
# via -r src/backend/requirements.in
|
# via -r src/backend/requirements.in
|
||||||
python-fsutil==0.15.0 \
|
python-fsutil==0.15.0 \
|
||||||
--hash=sha256:8ae31def522916e35caf67723b8526fe6e5fcc1e160ea2dc23c845567708ca6e \
|
--hash=sha256:8ae31def522916e35caf67723b8526fe6e5fcc1e160ea2dc23c845567708ca6e \
|
||||||
@@ -1784,9 +1883,9 @@ s3transfer==0.14.0 \
|
|||||||
--hash=sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456 \
|
--hash=sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456 \
|
||||||
--hash=sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125
|
--hash=sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125
|
||||||
# via boto3
|
# via boto3
|
||||||
sentry-sdk==2.42.1 \
|
sentry-sdk==2.43.0 \
|
||||||
--hash=sha256:8598cc6edcfe74cb8074ba6a7c15338cdee93d63d3eb9b9943b4b568354ad5b6 \
|
--hash=sha256:4aacafcf1756ef066d359ae35030881917160ba7f6fc3ae11e0e58b09edc2d5d \
|
||||||
--hash=sha256:f8716b50c927d3beb41bc88439dc6bcd872237b596df5b14613e2ade104aee02
|
--hash=sha256:52ed6e251c5d2c084224d73efee56b007ef5c2d408a4a071270e82131d336e20
|
||||||
# via
|
# via
|
||||||
# -r src/backend/requirements.in
|
# -r src/backend/requirements.in
|
||||||
# django-q-sentry
|
# django-q-sentry
|
||||||
|
|||||||
@@ -125,6 +125,7 @@
|
|||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-macros": "^3.1.0",
|
||||||
"nyc": "^17.1.0",
|
"nyc": "^17.1.0",
|
||||||
|
"otpauth": "^9.4.1",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"rollup": "^4.0.0",
|
"rollup": "^4.0.0",
|
||||||
"rollup-plugin-license": "^3.5.3",
|
"rollup-plugin-license": "^3.5.3",
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { ActionButton } from '@lib/components/ActionButton';
|
import { ActionButton } from '@lib/components/ActionButton';
|
||||||
|
import type { FloatingPosition } from '@mantine/core';
|
||||||
import { InvenTreeIcon } from '../../functions/icons';
|
import { InvenTreeIcon } from '../../functions/icons';
|
||||||
|
|
||||||
export default function RemoveRowButton({
|
export default function RemoveRowButton({
|
||||||
onClick,
|
onClick,
|
||||||
tooltip = t`Remove this row`
|
tooltip = t`Remove this row`,
|
||||||
|
tooltipAlignment
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
|
tooltipAlignment?: FloatingPosition;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
icon={<InvenTreeIcon icon='square_x' />}
|
icon={<InvenTreeIcon icon='square_x' />}
|
||||||
tooltip={tooltip}
|
tooltip={tooltip}
|
||||||
tooltipAlignment='top-end'
|
tooltipAlignment={tooltipAlignment ?? 'top-end'}
|
||||||
color='red'
|
color='red'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { t } from '@lingui/core/macro';
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
CloseButton,
|
CloseButton,
|
||||||
Code,
|
|
||||||
Group,
|
Group,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
Overlay,
|
Overlay,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs
|
Tabs
|
||||||
@@ -91,7 +92,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
const [hasSaveConfirmed, setHasSaveConfirmed] = useState(false);
|
const [hasSaveConfirmed, setHasSaveConfirmed] = useState(false);
|
||||||
|
|
||||||
const [previewItem, setPreviewItem] = useState<string>('');
|
const [previewItem, setPreviewItem] = useState<string>('');
|
||||||
const [errorOverlay, setErrorOverlay] = useState(null);
|
const [renderingErrors, setRenderingErrors] = useState<string[] | null>(null);
|
||||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||||
|
|
||||||
const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
|
const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
|
||||||
@@ -210,7 +211,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setErrorOverlay(null);
|
setRenderingErrors(null);
|
||||||
|
|
||||||
notifications.hide('template-preview');
|
notifications.hide('template-preview');
|
||||||
|
|
||||||
@@ -222,7 +223,19 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setErrorOverlay(error.message);
|
const msg = error?.message;
|
||||||
|
|
||||||
|
if (msg) {
|
||||||
|
if (Array.isArray(msg)) {
|
||||||
|
setRenderingErrors(msg);
|
||||||
|
} else {
|
||||||
|
setRenderingErrors([msg]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setRenderingErrors([
|
||||||
|
t`An unknown error occurred while rendering the preview.`
|
||||||
|
]);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsPreviewLoading(false);
|
setIsPreviewLoading(false);
|
||||||
@@ -392,10 +405,10 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
{/* @ts-ignore-next-line */}
|
{/* @ts-ignore-next-line */}
|
||||||
<PreviewArea.component ref={previewRef} />
|
<PreviewArea.component ref={previewRef} />
|
||||||
|
|
||||||
{errorOverlay && (
|
{renderingErrors && (
|
||||||
<Overlay color='red' center blur={0.2}>
|
<Overlay color='red' center blur={0.2}>
|
||||||
<CloseButton
|
<CloseButton
|
||||||
onClick={() => setErrorOverlay(null)}
|
onClick={() => setRenderingErrors(null)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '10px',
|
top: '10px',
|
||||||
@@ -410,7 +423,11 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
title={t`Error rendering template`}
|
title={t`Error rendering template`}
|
||||||
mx='10px'
|
mx='10px'
|
||||||
>
|
>
|
||||||
<Code>{errorOverlay}</Code>
|
<List>
|
||||||
|
{renderingErrors.map((error, index) => (
|
||||||
|
<ListItem key={index}>{error}</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { ActionIcon, Alert, Group, Menu, Stack, Tooltip } from '@mantine/core';
|
|||||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import type { SettingsStateProps } from '@lib/types/Settings';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { docLinks } from '../../defaults/links';
|
import { docLinks } from '../../defaults/links';
|
||||||
import { useServerApiState } from '../../states/ServerApiState';
|
import { useServerApiState } from '../../states/ServerApiState';
|
||||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
|
import type { ServerAPIProps } from '../../states/states';
|
||||||
|
|
||||||
interface AlertInfo {
|
interface AlertInfo {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -32,64 +34,21 @@ export function Alerts() {
|
|||||||
|
|
||||||
const [dismissed, setDismissed] = useState<string[]>([]);
|
const [dismissed, setDismissed] = useState<string[]>([]);
|
||||||
|
|
||||||
const alerts: AlertInfo[] = useMemo(() => {
|
const alerts: AlertInfo[] = useMemo(
|
||||||
const _alerts: AlertInfo[] = [];
|
() =>
|
||||||
|
getAlerts(server, globalSettings).filter(
|
||||||
if (server?.debug_mode) {
|
(alert) => !dismissed.includes(alert.key)
|
||||||
_alerts.push({
|
),
|
||||||
key: 'debug',
|
[server, dismissed, globalSettings]
|
||||||
title: t`Debug Mode`,
|
);
|
||||||
code: 'INVE-W4',
|
|
||||||
message: t`The server is running in debug mode.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!server?.worker_running) {
|
|
||||||
_alerts.push({
|
|
||||||
key: 'worker',
|
|
||||||
title: t`Background Worker`,
|
|
||||||
code: 'INVE-W5',
|
|
||||||
message: t`The background worker process is not running.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!server?.email_configured) {
|
|
||||||
_alerts.push({
|
|
||||||
key: 'email',
|
|
||||||
title: t`Email settings`,
|
|
||||||
code: 'INVE-W7',
|
|
||||||
message: t`Email settings not configured.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (globalSettings.isSet('SERVER_RESTART_REQUIRED')) {
|
|
||||||
_alerts.push({
|
|
||||||
key: 'restart',
|
|
||||||
title: t`Server Restart`,
|
|
||||||
code: 'INVE-W6',
|
|
||||||
message: t`The server requires a restart to apply changes.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const n_migrations =
|
|
||||||
Number.parseInt(globalSettings.getSetting('_PENDING_MIGRATIONS')) ?? 0;
|
|
||||||
|
|
||||||
if (n_migrations > 0) {
|
|
||||||
_alerts.push({
|
|
||||||
key: 'migrations',
|
|
||||||
title: t`Database Migrations`,
|
|
||||||
code: 'INVE-W8',
|
|
||||||
message: t`There are pending database migrations.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return _alerts.filter((alert) => !dismissed.includes(alert.key));
|
|
||||||
}, [server, dismissed, globalSettings]);
|
|
||||||
|
|
||||||
const anyErrors: boolean = useMemo(
|
const anyErrors: boolean = useMemo(
|
||||||
() => alerts.some((alert) => alert.error),
|
() => alerts.some((alert) => alert.error),
|
||||||
[alerts]
|
[alerts]
|
||||||
);
|
);
|
||||||
|
function closeAlert(key: string) {
|
||||||
|
setDismissed([...dismissed, key]);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.isStaff() && alerts.length > 0)
|
if (user.isStaff() && alerts.length > 0)
|
||||||
return (
|
return (
|
||||||
@@ -108,22 +67,7 @@ export function Alerts() {
|
|||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<Menu.Item key={`alert-item-${alert.key}`}>
|
<Menu.Item key={`alert-item-${alert.key}`}>
|
||||||
<Alert
|
<ServerAlert alert={alert} closeAlert={closeAlert} />
|
||||||
withCloseButton
|
|
||||||
color={alert.error ? 'red' : 'orange'}
|
|
||||||
title={
|
|
||||||
<Group gap='xs'>
|
|
||||||
{alert.code && `${alert.code}: `}
|
|
||||||
{alert.title}
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
onClose={() => setDismissed([...dismissed, alert.key])}
|
|
||||||
>
|
|
||||||
<Stack gap='xs'>
|
|
||||||
{alert.message}
|
|
||||||
{alert.code && errorCodeLink(alert.code)}
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
))}
|
))}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
@@ -131,6 +75,84 @@ export function Alerts() {
|
|||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ServerAlert({
|
||||||
|
alert,
|
||||||
|
closeAlert
|
||||||
|
}: { alert: AlertInfo; closeAlert?: (key: string) => void }) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
withCloseButton={!!closeAlert}
|
||||||
|
color={alert.error ? 'red' : 'orange'}
|
||||||
|
title={
|
||||||
|
<Group gap='xs'>
|
||||||
|
{alert.code && `${alert.code}: `}
|
||||||
|
{alert.title}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
onClose={closeAlert ? () => closeAlert(alert.key) : undefined}
|
||||||
|
>
|
||||||
|
<Stack gap='xs'>
|
||||||
|
{alert.message}
|
||||||
|
{alert.code && errorCodeLink(alert.code)}
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtendedAlertInfo = AlertInfo & {
|
||||||
|
condition: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAlerts(
|
||||||
|
server: ServerAPIProps,
|
||||||
|
globalSettings: SettingsStateProps,
|
||||||
|
inactive = false
|
||||||
|
): ExtendedAlertInfo[] {
|
||||||
|
const n_migrations =
|
||||||
|
Number.parseInt(globalSettings.getSetting('_PENDING_MIGRATIONS')) ?? 0;
|
||||||
|
|
||||||
|
const allalerts: ExtendedAlertInfo[] = [
|
||||||
|
{
|
||||||
|
key: 'debug',
|
||||||
|
title: t`Debug Mode`,
|
||||||
|
code: 'INVE-W4',
|
||||||
|
message: t`The server is running in debug mode.`,
|
||||||
|
condition: server?.debug_mode || false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'worker',
|
||||||
|
title: t`Background Worker`,
|
||||||
|
code: 'INVE-W5',
|
||||||
|
message: t`The background worker process is not running.`,
|
||||||
|
condition: !server?.worker_running
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'restart',
|
||||||
|
title: t`Server Restart`,
|
||||||
|
code: 'INVE-W6',
|
||||||
|
message: t`The server requires a restart to apply changes.`,
|
||||||
|
condition: globalSettings.isSet('SERVER_RESTART_REQUIRED')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
title: t`Email settings`,
|
||||||
|
code: 'INVE-W7',
|
||||||
|
message: t`Email settings not configured.`,
|
||||||
|
condition: !server?.email_configured
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'migrations',
|
||||||
|
title: t`Database Migrations`,
|
||||||
|
code: 'INVE-W8',
|
||||||
|
message: t`There are pending database migrations.`,
|
||||||
|
condition: n_migrations > 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return allalerts.filter((alert) => inactive || alert.condition);
|
||||||
|
}
|
||||||
|
|
||||||
export function errorCodeLink(code: string) {
|
export function errorCodeLink(code: string) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Container, Flex, Space } from '@mantine/core';
|
import { Container, Flex, Space } from '@mantine/core';
|
||||||
import { Spotlight, createSpotlight } from '@mantine/spotlight';
|
import {
|
||||||
|
Spotlight,
|
||||||
|
type SpotlightActionData,
|
||||||
|
createSpotlight
|
||||||
|
} from '@mantine/spotlight';
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import { type JSX, useEffect, useState } from 'react';
|
import { type JSX, useEffect, useMemo, useState } from 'react';
|
||||||
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { identifierString } from '@lib/functions/Conversion';
|
||||||
|
import { ApiEndpoints, apiUrl } from '@lib/index';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../App';
|
||||||
import { getActions } from '../../defaults/actions';
|
import { getActions } from '../../defaults/actions';
|
||||||
import * as classes from '../../main.css';
|
import * as classes from '../../main.css';
|
||||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
import {
|
||||||
|
useGlobalSettingsState,
|
||||||
|
useUserSettingsState
|
||||||
|
} from '../../states/SettingsStates';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import { Boundary } from '../Boundary';
|
import { Boundary } from '../Boundary';
|
||||||
|
import { ApiIcon } from '../items/ApiIcon';
|
||||||
|
import { useInvenTreeContext } from '../plugins/PluginContext';
|
||||||
|
import { callExternalPluginFunction } from '../plugins/PluginSource';
|
||||||
|
import {
|
||||||
|
type PluginUIFeature,
|
||||||
|
PluginUIFeatureType
|
||||||
|
} from '../plugins/PluginUIFeature';
|
||||||
import { Footer } from './Footer';
|
import { Footer } from './Footer';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
|
|
||||||
@@ -38,25 +56,65 @@ export const [firstStore, firstSpotlight] = createSpotlight();
|
|||||||
export default function LayoutComponent() {
|
export default function LayoutComponent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const user = useUserState();
|
||||||
const userSettings = useUserSettingsState();
|
const userSettings = useUserSettingsState();
|
||||||
|
const globalSettings = useGlobalSettingsState();
|
||||||
|
|
||||||
|
const pluginsEnabled: boolean = useMemo(
|
||||||
|
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
|
||||||
|
[globalSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inventreeContext = useInvenTreeContext();
|
||||||
|
|
||||||
const defaultActions = getActions(navigate);
|
const defaultActions = getActions(navigate);
|
||||||
const [actions, setActions] = useState(defaultActions);
|
const [actions, setActions] = useState(defaultActions);
|
||||||
const [customActions, setCustomActions] = useState<boolean>(false);
|
|
||||||
|
|
||||||
function actionsAreChanging(change: []) {
|
const pluginActionsQuery = useQuery({
|
||||||
if (change.length > defaultActions.length) setCustomActions(true);
|
enabled: pluginsEnabled,
|
||||||
setActions(change);
|
queryKey: ['plugin-actions', pluginsEnabled, user],
|
||||||
|
refetchOnMount: true,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!pluginsEnabled) {
|
||||||
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
// firstStore.subscribe(actionsAreChanging);
|
|
||||||
|
|
||||||
// clear additional actions on location change
|
const url = apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, {
|
||||||
|
feature_type: PluginUIFeatureType.spotlight_action
|
||||||
|
});
|
||||||
|
|
||||||
|
return api.get(url).then((response: any) => response.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pluginActions: SpotlightActionData[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
pluginActionsQuery?.data?.map((item: PluginUIFeature) => {
|
||||||
|
const pluginContext = {
|
||||||
|
...inventreeContext,
|
||||||
|
context: item.context
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: identifierString(`a-${item.plugin_name}-${item.key}`),
|
||||||
|
label: item.title,
|
||||||
|
description: item.description,
|
||||||
|
leftSection: item.icon && <ApiIcon name={item.icon} />,
|
||||||
|
onClick: () => {
|
||||||
|
callExternalPluginFunction(
|
||||||
|
item.source,
|
||||||
|
'executeAction',
|
||||||
|
pluginContext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}, [pluginActionsQuery?.data, inventreeContext]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (customActions) {
|
setActions([...defaultActions, ...pluginActions]);
|
||||||
setActions(defaultActions);
|
}, [defaultActions.length, pluginActions.length, location]);
|
||||||
setCustomActions(false);
|
|
||||||
}
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { InvenTreePluginContext } from '@lib/types/Plugins';
|
||||||
import { generateUrl } from '../../functions/urls';
|
import { generateUrl } from '../../functions/urls';
|
||||||
import { useLocalState } from '../../states/LocalState';
|
import { useLocalState } from '../../states/LocalState';
|
||||||
|
|
||||||
@@ -56,3 +57,16 @@ export async function findExternalPluginFunction(
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attempt to call an external plugin function, given the source URL and function name
|
||||||
|
export async function callExternalPluginFunction(
|
||||||
|
source: string,
|
||||||
|
functionName: string,
|
||||||
|
context: InvenTreePluginContext
|
||||||
|
): Promise<any> {
|
||||||
|
findExternalPluginFunction(source, functionName).then((func) => {
|
||||||
|
if (func) {
|
||||||
|
return func(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
* Enumeration for available plugin UI feature types.
|
* Enumeration for available plugin UI feature types.
|
||||||
*/
|
*/
|
||||||
export enum PluginUIFeatureType {
|
export enum PluginUIFeatureType {
|
||||||
|
spotlight_action = 'spotlight_action',
|
||||||
dashboard = 'dashboard',
|
dashboard = 'dashboard',
|
||||||
panel = 'panel',
|
panel = 'panel',
|
||||||
template_editor = 'template_editor',
|
template_editor = 'template_editor',
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function RenderSupplierPart(
|
|||||||
const part = instance.part_detail ?? {};
|
const part = instance.part_detail ?? {};
|
||||||
|
|
||||||
const secondary: string = instance.SKU;
|
const secondary: string = instance.SKU;
|
||||||
let suffix: string = part.full_name;
|
let suffix: string = part?.full_name ?? '';
|
||||||
|
|
||||||
if (instance.pack_quantity) {
|
if (instance.pack_quantity) {
|
||||||
suffix += ` (${instance.pack_quantity})`;
|
suffix += ` (${instance.pack_quantity})`;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Button, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconBrandGithub,
|
||||||
|
IconListCheck,
|
||||||
|
IconUserPlus,
|
||||||
|
IconUsersGroup,
|
||||||
|
type ReactNode
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
import { ApiEndpoints } from '@lib/index';
|
||||||
|
import {
|
||||||
|
projectCodeFields,
|
||||||
|
useCustomStateFields
|
||||||
|
} from '../../forms/CommonForms';
|
||||||
|
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||||
|
import { groupFields } from '../../tables/settings/GroupTable';
|
||||||
|
import { userFields } from '../../tables/settings/UserTable';
|
||||||
|
|
||||||
|
interface ActionItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
buttonText?: string;
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionGrid({ items }: { items: ActionItem[] }) {
|
||||||
|
const slides = items.map((image) => (
|
||||||
|
<Paper shadow='xs' p='sm' withBorder>
|
||||||
|
<Group justify='space-between' wrap='nowrap'>
|
||||||
|
<Stack>
|
||||||
|
<Text>
|
||||||
|
<strong>{image.title}</strong>
|
||||||
|
<br />
|
||||||
|
{image.description}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='light'
|
||||||
|
onClick={image.action}
|
||||||
|
leftSection={image.icon}
|
||||||
|
>
|
||||||
|
{image.buttonText ?? <Trans>Act</Trans>}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleGrid
|
||||||
|
cols={{
|
||||||
|
base: 1,
|
||||||
|
'600px': 2,
|
||||||
|
'1200px': 3
|
||||||
|
}}
|
||||||
|
type='container'
|
||||||
|
spacing='sm'
|
||||||
|
>
|
||||||
|
{slides}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickAction = () => {
|
||||||
|
const newUser = useCreateApiFormModal(userFields());
|
||||||
|
const newGroup = useCreateApiFormModal(groupFields());
|
||||||
|
const newProjectCode = useCreateApiFormModal({
|
||||||
|
url: ApiEndpoints.project_code_list,
|
||||||
|
title: t`Add Project Code`,
|
||||||
|
fields: projectCodeFields()
|
||||||
|
});
|
||||||
|
const newCustomState = useCreateApiFormModal({
|
||||||
|
url: ApiEndpoints.custom_state_list,
|
||||||
|
title: t`Add State`,
|
||||||
|
fields: useCustomStateFields()
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: '0',
|
||||||
|
title: t`Open an Issue`,
|
||||||
|
description: t`Report a bug or request a feature on GitHub`,
|
||||||
|
icon: <IconBrandGithub />,
|
||||||
|
buttonText: t`Open Issue`,
|
||||||
|
action: () =>
|
||||||
|
window.open(
|
||||||
|
'https://github.com/inventree/inventree/issues/new',
|
||||||
|
'_blank'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: t`Add New Group`,
|
||||||
|
description: t`Create a new group to manage your users`,
|
||||||
|
icon: <IconUsersGroup />,
|
||||||
|
buttonText: t`New Group`,
|
||||||
|
action: () => newGroup.open()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: t`Add New User`,
|
||||||
|
description: t`Create a new user to manage your groups`,
|
||||||
|
icon: <IconUserPlus />,
|
||||||
|
buttonText: t`New User`,
|
||||||
|
action: () => newUser.open()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: t`Add Project Code`,
|
||||||
|
description: t`Create a new project code to organize your items`,
|
||||||
|
icon: <IconListCheck />,
|
||||||
|
buttonText: t`Add Code`,
|
||||||
|
action: () => newProjectCode.open()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: t`Add Custom State`,
|
||||||
|
description: t`Create a new custom state for your workflow`,
|
||||||
|
icon: <IconListCheck />,
|
||||||
|
buttonText: t`Add State`,
|
||||||
|
action: () => newCustomState.open()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={'xs'} ml={'sm'}>
|
||||||
|
<ActionGrid items={items} />
|
||||||
|
{newUser.modal}
|
||||||
|
{newGroup.modal}
|
||||||
|
{newProjectCode.modal}
|
||||||
|
{newCustomState.modal}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,22 +3,163 @@ import { AddItemButton } from '@lib/components/AddItemButton';
|
|||||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
import { ModelType } from '@lib/enums/ModelType';
|
import { ModelType } from '@lib/enums/ModelType';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
|
import { formatDecimal } from '@lib/functions/Formatting';
|
||||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Alert, Group, Paper, Tooltip } from '@mantine/core';
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
HoverCard,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip
|
||||||
|
} from '@mantine/core';
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { IconShoppingCart } from '@tabler/icons-react';
|
import {
|
||||||
|
IconExclamationCircle,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShoppingCart
|
||||||
|
} from '@tabler/icons-react';
|
||||||
import { DataTable } from 'mantine-datatable';
|
import { DataTable } from 'mantine-datatable';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSupplierPartFields } from '../../forms/CompanyForms';
|
import { useSupplierPartFields } from '../../forms/CompanyForms';
|
||||||
import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms';
|
import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms';
|
||||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||||
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import useWizard from '../../hooks/UseWizard';
|
import useWizard from '../../hooks/UseWizard';
|
||||||
import { RenderPartColumn } from '../../tables/ColumnRenderers';
|
import { RenderPartColumn } from '../../tables/ColumnRenderers';
|
||||||
import RemoveRowButton from '../buttons/RemoveRowButton';
|
import RemoveRowButton from '../buttons/RemoveRowButton';
|
||||||
import { StandaloneField } from '../forms/StandaloneField';
|
import { StandaloneField } from '../forms/StandaloneField';
|
||||||
import Expand from '../items/Expand';
|
import Expand from '../items/Expand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "requirements" info for a part
|
||||||
|
* This fetches the information dynamically from the API
|
||||||
|
*/
|
||||||
|
function PartRequirementsInfo({
|
||||||
|
partId,
|
||||||
|
onQuantityChange
|
||||||
|
}: {
|
||||||
|
partId: number | string;
|
||||||
|
onQuantityChange?: (quantity: number) => void;
|
||||||
|
}) {
|
||||||
|
const [requiredQuantity, setRequiredQuantity] = useState<number>(0);
|
||||||
|
|
||||||
|
// Notify parent component of quantity change
|
||||||
|
useEffect(() => {
|
||||||
|
onQuantityChange?.(requiredQuantity);
|
||||||
|
}, [requiredQuantity]);
|
||||||
|
|
||||||
|
const requirements = useInstance({
|
||||||
|
endpoint: ApiEndpoints.part_requirements,
|
||||||
|
pk: partId,
|
||||||
|
hasPrimaryKey: true,
|
||||||
|
defaultValue: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const widget = useMemo(() => {
|
||||||
|
if (
|
||||||
|
requirements.instanceQuery.isFetching ||
|
||||||
|
requirements.instanceQuery.isLoading
|
||||||
|
) {
|
||||||
|
return <Loader size='sm' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requirements.instanceQuery.isError) {
|
||||||
|
return (
|
||||||
|
<Tooltip label={t`Error fetching part requirements`}>
|
||||||
|
<ActionIcon variant='transparent' color='red'>
|
||||||
|
<IconExclamationCircle />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the total requirements
|
||||||
|
const buildRequirements =
|
||||||
|
requirements.instance?.required_for_build_orders || 0;
|
||||||
|
const salesRequirements =
|
||||||
|
requirements.instance?.required_for_sales_orders || 0;
|
||||||
|
const totalRequirements = buildRequirements + salesRequirements;
|
||||||
|
|
||||||
|
const building = requirements.instance?.building || 0;
|
||||||
|
const ordering = requirements.instance?.ordering || 0;
|
||||||
|
const incoming = building + ordering;
|
||||||
|
|
||||||
|
const inStock = requirements.instance?.total_stock || 0;
|
||||||
|
|
||||||
|
const required = Math.max(0, totalRequirements - inStock - incoming);
|
||||||
|
|
||||||
|
setRequiredQuantity(required);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard position='bottom-end'>
|
||||||
|
<HoverCard.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant='transparent'
|
||||||
|
color={required > 0 ? 'blue' : 'green'}
|
||||||
|
size='sm'
|
||||||
|
>
|
||||||
|
<IconInfoCircle />
|
||||||
|
</ActionIcon>
|
||||||
|
</HoverCard.Target>
|
||||||
|
<HoverCard.Dropdown>
|
||||||
|
<Stack gap='xs'>
|
||||||
|
<Text>{t`Requirements`}</Text>
|
||||||
|
<Divider />
|
||||||
|
{buildRequirements > 0 && (
|
||||||
|
<Group justify='space-between'>
|
||||||
|
<Text size='xs'>{t`Build Requirements`}</Text>
|
||||||
|
<Text size='xs'>{formatDecimal(buildRequirements)}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{salesRequirements > 0 && (
|
||||||
|
<Group justify='space-between'>
|
||||||
|
<Text size='xs'>{t`Sales Requirements`}</Text>
|
||||||
|
<Text size='xs'>{formatDecimal(salesRequirements)}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{inStock > 0 && (
|
||||||
|
<Group justify='space-between'>
|
||||||
|
<Text size='xs'>{t`In Stock`}</Text>
|
||||||
|
<Text size='xs'>{formatDecimal(inStock)}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{ordering > 0 && (
|
||||||
|
<Group justify='space-between'>
|
||||||
|
<Text size='xs'>{t`On Order`}</Text>
|
||||||
|
<Text size='xs'>{formatDecimal(ordering)}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{building > 0 && (
|
||||||
|
<Group justify='space-between'>
|
||||||
|
<Text size='xs'>{t`In Production`}</Text>
|
||||||
|
<Text size='xs'>{formatDecimal(building)}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
<Group justify='space-between'>
|
||||||
|
<Text size='xs'>{t`Required Quantity`}</Text>
|
||||||
|
<Text size='xs'>{formatDecimal(required)}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</HoverCard.Dropdown>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
requirements.instanceQuery.isFetching,
|
||||||
|
requirements.instanceQuery.isLoading,
|
||||||
|
requirements.instanceQuery.isError,
|
||||||
|
requirements.instance,
|
||||||
|
setRequiredQuantity
|
||||||
|
]);
|
||||||
|
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attributes for each selected part
|
* Attributes for each selected part
|
||||||
* - part: The part instance
|
* - part: The part instance
|
||||||
@@ -123,7 +264,10 @@ function SelectPartsStep({
|
|||||||
width: '1%',
|
width: '1%',
|
||||||
render: (record: PartOrderRecord) => (
|
render: (record: PartOrderRecord) => (
|
||||||
<Group gap='xs' wrap='nowrap' justify='left'>
|
<Group gap='xs' wrap='nowrap' justify='left'>
|
||||||
<RemoveRowButton onClick={() => onRemovePart(record.part)} />
|
<RemoveRowButton
|
||||||
|
tooltipAlignment={'top-start'}
|
||||||
|
onClick={() => onRemovePart(record.part)}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -162,6 +306,7 @@ function SelectPartsStep({
|
|||||||
filters: {
|
filters: {
|
||||||
part: record.part.pk,
|
part: record.part.pk,
|
||||||
active: true,
|
active: true,
|
||||||
|
part_detail: true,
|
||||||
supplier_detail: true
|
supplier_detail: true
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -169,7 +314,7 @@ function SelectPartsStep({
|
|||||||
</Expand>
|
</Expand>
|
||||||
<AddItemButton
|
<AddItemButton
|
||||||
tooltip={t`New supplier part`}
|
tooltip={t`New supplier part`}
|
||||||
tooltipAlignment='top'
|
tooltipAlignment='top-end'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedRecord(record);
|
setSelectedRecord(record);
|
||||||
newSupplierPart.open();
|
newSupplierPart.open();
|
||||||
@@ -207,7 +352,7 @@ function SelectPartsStep({
|
|||||||
</Expand>
|
</Expand>
|
||||||
<AddItemButton
|
<AddItemButton
|
||||||
tooltip={t`New purchase order`}
|
tooltip={t`New purchase order`}
|
||||||
tooltipAlignment='top'
|
tooltipAlignment='top-end'
|
||||||
disabled={!record.supplier_part?.pk}
|
disabled={!record.supplier_part?.pk}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedRecord(record);
|
setSelectedRecord(record);
|
||||||
@@ -220,8 +365,9 @@ function SelectPartsStep({
|
|||||||
{
|
{
|
||||||
accessor: 'quantity',
|
accessor: 'quantity',
|
||||||
title: t`Quantity`,
|
title: t`Quantity`,
|
||||||
width: 125,
|
width: 150,
|
||||||
render: (record: PartOrderRecord) => (
|
render: (record: PartOrderRecord) => (
|
||||||
|
<Group gap='xs' wrap='nowrap'>
|
||||||
<StandaloneField
|
<StandaloneField
|
||||||
fieldName='quantity'
|
fieldName='quantity'
|
||||||
hideLabels={true}
|
hideLabels={true}
|
||||||
@@ -235,6 +381,13 @@ function SelectPartsStep({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<PartRequirementsInfo
|
||||||
|
partId={record.part.pk}
|
||||||
|
onQuantityChange={(quantity: number) =>
|
||||||
|
onSelectQuantity(record.part.pk, quantity)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -255,7 +408,7 @@ function SelectPartsStep({
|
|||||||
}
|
}
|
||||||
icon={<IconShoppingCart />}
|
icon={<IconShoppingCart />}
|
||||||
tooltip={t`Add to selected purchase order`}
|
tooltip={t`Add to selected purchase order`}
|
||||||
tooltipAlignment='top'
|
tooltipAlignment='top-end'
|
||||||
color='blue'
|
color='blue'
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user