2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-13 20:10:49 +00:00

Merge branch 'generic-parameters' of github.com:SchrodingersGat/InvenTree into generic-parameters

This commit is contained in:
Oliver Walters
2025-11-10 04:29:49 +00:00
179 changed files with 48437 additions and 39123 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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
View File
@@ -1 +0,0 @@
3.9.2
+16 -4
View File
@@ -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.
+23 -23
View File
@@ -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
View File
@@ -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[@]}"
+3 -13
View File
@@ -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"
+4 -4
View File
@@ -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 "####################################################################################"
} }
+1 -1
View File
@@ -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
+14
View File
@@ -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:
+33 -3
View File
@@ -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
+39 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 \
-3
View File
@@ -1,3 +0,0 @@
# Dummy requirements file to trigger the package pipeline
# The backend requirements file is located in src/backend/requirements.txt
#
-1
View File
@@ -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
+2 -2
View File
@@ -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({
+12 -1
View File
@@ -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
+14 -2
View File
@@ -321,6 +321,18 @@ class InvenTreeConfig(AppConfig):
return return
if not InvenTree.tasks.check_for_migrations(): if not InvenTree.tasks.check_for_migrations():
logger.error('INVE-W8: Database Migrations required') # Detect if this an empty database - if so, start with a fresh migration
sys.exit(1) 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')
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))
@@ -0,0 +1,22 @@
"""Helper functions for allauth MFA testing."""
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
from allauth.mfa.totp.internal import auth as allauth_totp_auth
def get_codes(user):
"""Generate active TOTP and recovery codes for a user.
Args:
user: User instance
Returns:
Tuple of (TOTP Authenticator instance, list of recovery codes, TOTP secret)
"""
secret = allauth_totp_auth.generate_totp_secret()
totp_auth = allauth_totp_auth.TOTP.activate(user, secret).instance
rc_auth = RecoveryCodes.activate(user).instance
# Get usable codes
rc_codes = rc_auth.wrap().get_unused_codes()
return totp_auth, rc_codes, secret
+138 -53
View File
@@ -1,21 +1,22 @@
"""Middleware for InvenTree.""" """Middleware for InvenTree."""
import sys import sys
from typing import Optional
from urllib.parse import urlsplit 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, 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
@@ -40,6 +41,15 @@ def get_token_from_request(request):
return None return None
def ensure_slashes(path: str):
"""Ensure that slashes are suroudning the passed path."""
if not path.startswith('/'):
path = f'/{path}'
if not path.endswith('/'):
path = f'{path}/'
return path
# List of target URL endpoints where *do not* want to redirect to # List of target URL endpoints where *do not* want to redirect to
urls = [ urls = [
reverse_lazy('account_login'), reverse_lazy('account_login'),
@@ -47,8 +57,46 @@ urls = [
reverse_lazy('admin:logout'), reverse_lazy('admin:logout'),
] ]
# Do not redirect requests to any of these paths paths_ignore_handling = [
paths_ignore = ['/api/', '/auth/', settings.MEDIA_URL, settings.STATIC_URL] '/api/',
reverse('auth-check'),
settings.MEDIA_URL,
settings.STATIC_URL,
]
"""Paths that should not use InvenTrees own auth rejection behaviour, no host checking or redirecting. Security
are still enforced."""
paths_own_security = [
'/api/', # DRF handles API
'/o/', # oAuth2 library - has its own auth model
'/anymail/', # Mails - wehbhooks etc
'/accounts/', # allauth account management - has its own auth model
'/assets/', # Web assets - only used for testing, no security model needed
ensure_slashes(
settings.STATIC_URL
), # Static files - static files are considered safe to serve
ensure_slashes(
settings.FRONTEND_URL_BASE
), # Frontend files - frontend paths have their own security model
]
"""Paths that handle their own security model."""
pages_mfa_bypass = [
'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',
]
"""Exact page names that bypass MFA enforcement - normal security model is still enforced."""
apps_mfa_bypass = [
'headless' # Headless allauth app - has its own security model
]
"""App namespaces that bypass MFA enforcement - normal security model is still enforced."""
class AuthRequiredMiddleware: class AuthRequiredMiddleware:
@@ -61,6 +109,7 @@ class AuthRequiredMiddleware:
def check_token(self, request) -> bool: def check_token(self, request) -> bool:
"""Check if the user is authenticated via token.""" """Check if the user is authenticated via token."""
if token := get_token_from_request(request): if token := get_token_from_request(request):
request.token = token
# Does the provided token match a valid user? # Does the provided token match a valid user?
try: try:
token = ApiToken.objects.get(key=token) token = ApiToken.objects.get(key=token)
@@ -69,8 +118,10 @@ class AuthRequiredMiddleware:
# Provide the user information to the request # Provide the user information to the request
request.user = token.user request.user = token.user
return True return True
except ApiToken.DoesNotExist: except ApiToken.DoesNotExist: # pragma: no cover
logger.warning('Access denied for unknown token %s', token) logger.warning(
'Access denied for unknown token %s', token
) # pragma: no cover
return False return False
@@ -79,79 +130,113 @@ class AuthRequiredMiddleware:
Redirects to login if not authenticated. Redirects to login if not authenticated.
""" """
path: str = request.path_info
# Code to be executed for each request before # Code to be executed for each request before
# the view (and later middleware) are called. # the view (and later middleware) are called.
assert hasattr(request, 'user') assert hasattr(request, 'user')
# API requests are handled by the DRF library # API requests that are handled elsewhere
if request.path_info.startswith('/api/'): if any(path.startswith(a) for a in paths_own_security):
return self.get_response(request)
# oAuth2 requests are handled by the oAuth2 library
if request.path_info.startswith('/o/'):
return self.get_response(request)
# anymail requests are handled by the anymail library
if request.path_info.startswith('/anymail/'):
return self.get_response(request) return self.get_response(request)
# Is the function exempt from auth requirements? # Is the function exempt from auth requirements?
path_func = resolve(request.path).func path_func = resolve(request.path).func
if getattr(path_func, 'auth_exempt', False) is True: if getattr(path_func, 'auth_exempt', False) is True:
return self.get_response(request) return self.get_response(request)
if not request.user.is_authenticated: if not request.user.is_authenticated and not (
path == f'/{settings.FRONTEND_URL_BASE}' or self.check_token(request)
):
""" """
Normally, a web-based session would use csrftoken based authentication. Normally, a web-based session would use csrftoken based authentication.
However when running an external application (e.g. the InvenTree app or Python library), However when running an external application (e.g. the InvenTree app or Python library),
we must validate the user token manually. we must validate the user token manually.
""" """
if path not in urls and not any(
authorized = False path.startswith(p) for p in paths_ignore_handling
# Allow static files to be accessed without auth
# Important for e.g. login page
if (
request.path_info.startswith('/static/')
or request.path_info.startswith('/accounts/')
or (
request.path_info.startswith(f'/{settings.FRONTEND_URL_BASE}/')
or request.path_info.startswith('/assets/')
or request.path_info == f'/{settings.FRONTEND_URL_BASE}'
)
or self.check_token(request)
): ):
authorized = True # Save the 'next' parameter to pass through to the login view
# No authorization was found for the request return redirect(f'{reverse_lazy("account_login")}?next={request.path}')
if not authorized: # Return a 401 (Unauthorized) response code for this request
path = request.path_info return HttpResponse('Unauthorized', status=401)
if path not in urls and not any(
path.startswith(p) for p in paths_ignore
):
# Save the 'next' parameter to pass through to the login view
return redirect(
f'{reverse_lazy("account_login")}?next={request.path}'
)
# Return a 401 (Unauthorized) response code for this request
return HttpResponse('Unauthorized', status=401)
response = self.get_response(request) response = self.get_response(request)
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
"""
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 (
False
if match is None
else any(ref in apps_mfa_bypass for ref in match.app_names)
or match.url_name in pages_mfa_bypass
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
) -> Optional[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 getattr(
request, 'token', get_token_from_request(request)
): # Token based login can not do MFA
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."""
return get_global_setting('LOGIN_ENFORCE_MFA') return get_global_setting(
'LOGIN_ENFORCE_MFA', None, 'INVENTREE_LOGIN_ENFORCE_MFA'
)
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware): class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
@@ -232,7 +317,7 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
# Handle commonly ignored paths that might also work without a correct setup (api, auth) # Handle commonly ignored paths that might also work without a correct setup (api, auth)
path = request.path_info path = request.path_info
if path in urls or any(path.startswith(p) for p in paths_ignore): if path in urls or any(path.startswith(p) for p in paths_ignore_handling):
return None return None
# treat the accessed scheme and host # treat the accessed scheme and host
@@ -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,
)
+33 -91
View File
@@ -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'}, 'BACKEND': InvenTree.backup.get_backup_storage_backend(),
'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'}, 'OPTIONS': InvenTree.backup.get_backup_storage_options(),
'dbbackup': {
'BACKEND': InvenTree.backup.get_backup_storage_backend(),
'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')
'INVENTREE_MFA_SUPPORTED_TYPES',
'mfa_supported_types', MFA_SUPPORTED_TYPES = (
['totp', 'recovery_codes', 'webauthn'], get_setting(
typecast=list, 'INVENTREE_MFA_SUPPORTED_TYPES',
'mfa_supported_types',
['totp', 'recovery_codes', 'webauthn'],
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
+6
View File
@@ -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:
@@ -108,6 +108,13 @@ class ApiAccessTests(InvenTreeAPITestCase):
self.tokenAuth() self.tokenAuth()
self.assertIsNotNone(self.token) self.assertIsNotNone(self.token)
# Run explicit test with token auth
url = reverse('api-license')
response = self.get(
url, headers={'Authorization': f'Token {self.token}'}, expected_code=200
)
self.assertIn('backend', response.json())
def test_role_view(self): def test_role_view(self):
"""Test that we can access the 'roles' view for the logged in user. """Test that we can access the 'roles' view for the logged in user.
+24 -2
View File
@@ -4,6 +4,7 @@ from django.contrib.auth.models import Group, User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import override_settings from django.test import override_settings
from django.test.testcases import TransactionTestCase from django.test.testcases import TransactionTestCase
from django.urls import reverse
from allauth.socialaccount.models import SocialAccount, SocialLogin from allauth.socialaccount.models import SocialAccount, SocialLogin
@@ -139,13 +140,15 @@ class TestAuth(InvenTreeAPITestCase):
"""Test authentication functionality.""" """Test authentication functionality."""
reg_url = '/api/auth/v1/auth/signup' reg_url = '/api/auth/v1/auth/signup'
login_url = '/api/auth/v1/auth/login'
test_email = 'tester@example.com' test_email = 'tester@example.com'
def test_buildin_token(self): def test_buildin_token(self):
"""Test the built-in token authentication.""" """Test the built-in token authentication."""
self.logout() self.logout()
response = self.post( response = self.post(
'/api/auth/v1/auth/login', self.login_url,
{'username': self.username, 'password': self.password}, {'username': self.username, 'password': self.password},
expected_code=200, expected_code=200,
) )
@@ -155,7 +158,7 @@ class TestAuth(InvenTreeAPITestCase):
# Test for conflicting login # Test for conflicting login
self.post( self.post(
'/api/auth/v1/auth/login', self.login_url,
{'username': self.username, 'password': self.password}, {'username': self.username, 'password': self.password},
expected_code=409, expected_code=409,
) )
@@ -222,3 +225,22 @@ class TestAuth(InvenTreeAPITestCase):
): ):
resp = self.post(self.reg_url, self.email_args(), expected_code=200) resp = self.post(self.reg_url, self.email_args(), expected_code=200)
self.assertEqual(resp.json()['data']['user']['email'], self.test_email) self.assertEqual(resp.json()['data']['user']['email'], self.test_email)
def test_auth_request(self):
"""Test the auth_request view."""
url = reverse('auth-check')
# Logged in user
self.get(url)
# Inactive user
# TODO @matmair - this part of auth_request is not triggering currently
# self.user.is_active = False
# self.user.save()
# self.get(url, expected_code=403)
# self.user.is_active = True
# self.user.save()
# Logged out user
self.client.logout()
self.get(url, expected_code=401)
@@ -1,5 +1,7 @@
"""Tests for middleware functions.""" """Tests for middleware functions."""
from unittest.mock import patch
from django.conf import settings from django.conf import settings
from django.http import Http404 from django.http import Http404
from django.urls import reverse from django.urls import reverse
@@ -7,17 +9,19 @@ from django.urls import reverse
from error_report.models import Error from error_report.models import Error
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from InvenTree.helpers_mfa import get_codes
from InvenTree.unit_test import InvenTreeTestCase from InvenTree.unit_test import InvenTreeTestCase
class MiddlewareTests(InvenTreeTestCase): class MiddlewareTests(InvenTreeTestCase):
"""Test for middleware functions.""" """Test for middleware functions."""
def check_path(self, url, code=200, **kwargs): def check_path(self, url, code=200, auth_header=None, **kwargs):
"""Helper function to run a request.""" """Helper function to run a request."""
response = self.client.get( headers = {'accept': 'application/json'}
url, headers={'accept': 'application/json'}, **kwargs if auth_header:
) headers['Authorization'] = auth_header
response = self.client.get(url, headers=headers, **kwargs)
self.assertEqual(response.status_code, code) self.assertEqual(response.status_code, code)
return response return response
@@ -36,13 +40,62 @@ class MiddlewareTests(InvenTreeTestCase):
response = self.check_path(reverse('index'), 302) response = self.check_path(reverse('index'), 302)
self.assertEqual(response.url, '/accounts/login/?next=/') self.assertEqual(response.url, '/accounts/login/?next=/')
def test_Check2FAMiddleware(self):
"""Test the 2FA middleware."""
url = reverse('api-part-list')
self.assignRole(role='part.view', group=self.group)
# Ensure that normal access works with mfa enabled
with self.settings(MFA_ENABLED=True):
self.check_path(url)
# Ensure that normal access works with mfa disabled
with self.settings(MFA_ENABLED=False):
self.check_path(url)
# Now enforce MFA for the user
with self.settings(MFA_ENABLED=True) and patch.dict(
'os.environ', {'INVENTREE_LOGIN_ENFORCE_MFA': 'True'}
):
# Enforced but not logged in via mfa -> should give 403
response = self.check_path(url, 401)
self.assertContains(
response,
'You must enable two-factor authentication before doing anything else.',
status_code=401,
)
# Register a token and try again
rc_codes = get_codes(self.user)[1]
self.client.logout()
# Login step 1
self.client.post(
reverse('browser:account:login'),
{'username': self.username, 'password': self.password},
content_type='application/json',
)
# Login step 2
self.client.post(
reverse('browser:mfa:authenticate'),
{'code': rc_codes[0]},
expected_code=401,
content_type='application/json',
)
rsp3 = self.client.post(
reverse('browser:mfa:trust'),
{'trust': False},
expected_code=200,
content_type='application/json',
)
self.assertEqual(rsp3.status_code, 200)
self.check_path(url)
def test_token_auth(self): def test_token_auth(self):
"""Test auth with token auth.""" """Test auth with token auth."""
target = reverse('api-license') target = reverse('api-license')
# get token # get token
# response = self.client.get(reverse('api-token'), format='json', data={}) response = self.client.get(reverse('api-token'), format='json', data={})
# token = response.data['token'] token = response.data['token']
# logout # logout
self.client.logout() self.client.logout()
@@ -51,13 +104,16 @@ class MiddlewareTests(InvenTreeTestCase):
self.check_path(target, 401) self.check_path(target, 401)
# Request with broken token # Request with broken token
self.check_path(target, 401, HTTP_Authorization='Token abcd123') self.check_path(target, 401, auth_header='Token abcd123')
# should still fail without token # should still fail without token
self.check_path(target, 401) self.check_path(target, 401)
# request with token # request with token - should work
# self.check_path(target, HTTP_Authorization=f'Token {token}') self.check_path(target, auth_header=f'Token {token}')
# Request something that is not on the API - should still work
self.check_path(reverse('auth-check'), auth_header=f'Token {token}')
def test_error_exceptions(self): def test_error_exceptions(self):
"""Test that ignored errors are not logged.""" """Test that ignored errors are not logged."""
+1 -1
View File
@@ -188,7 +188,7 @@ class CorsTest(TestCase):
Here, we are not authorized by default, Here, we are not authorized by default,
but the CORS headers should still be included. but the CORS headers should still be included.
""" """
url = '/auth/' url = reverse('auth-check')
# First, a preflight request with a "valid" origin # First, a preflight request with a "valid" origin
+3 -1
View File
@@ -130,7 +130,9 @@ backendpatterns = [
path( path(
'auth/', include('rest_framework.urls', namespace='rest_framework') 'auth/', include('rest_framework.urls', namespace='rest_framework')
), # Used for (DRF) browsable API auth ), # Used for (DRF) browsable API auth
path('auth/', auth_request), # Used for proxies to check if user is authenticated path(
'auth/', auth_request, name='auth-check'
), # Used for proxies to check if user is authenticated
path('accounts/', include('allauth.urls')), path('accounts/', include('allauth.urls')),
# OAuth2 # OAuth2
flagged_path('OIDC', 'o/', include(oauth2_urls)), flagged_path('OIDC', 'o/', include(oauth2_urls)),
+1 -1
View File
@@ -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')
+10 -3
View File
@@ -12,6 +12,13 @@ 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 (
return HttpResponse(status=200) not request.user
return HttpResponse(status=403) or not request.user.is_authenticated
or not request.user.is_active
):
# This is very unlikely to be reached, as the middleware stack should intercept unauthenticated requests
return HttpResponse(status=403) # pragma: no cover
# User is authenticated and active
return HttpResponse(status=200)
+2
View File
@@ -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',
+1
View File
@@ -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
+12 -1
View File
@@ -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',
+16 -3
View File
@@ -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
) )
+33
View File
@@ -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)
+18 -1
View File
@@ -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:
+64
View File
@@ -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
+5 -1
View File
@@ -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',
] ]
+4 -32
View File
@@ -1,15 +1,12 @@
"""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
from django.urls import reverse from django.urls import reverse
from allauth.mfa.totp.internal import auth as totp_auth
from common.settings import set_global_setting from common.settings import set_global_setting
from InvenTree.helpers_mfa import get_codes
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
from users.models import ApiToken, Owner from users.models import ApiToken, Owner
from users.oauth2_scopes import _roles from users.oauth2_scopes import _roles
@@ -336,8 +333,6 @@ class OwnerModelTest(InvenTreeTestCase):
class MFALoginTest(InvenTreeAPITestCase): class MFALoginTest(InvenTreeAPITestCase):
"""Some simplistic tests to ensure that MFA is working.""" """Some simplistic tests to ensure that MFA is working."""
mfa_secret = None
def test_api(self): def test_api(self):
"""Test that the API is working.""" """Test that the API is working."""
auth_data = {'username': self.username, 'password': self.password} auth_data = {'username': self.username, 'password': self.password}
@@ -352,19 +347,7 @@ class MFALoginTest(InvenTreeAPITestCase):
self._helper_meta_val(response) self._helper_meta_val(response)
# 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 rc_code = get_codes(user=self.user)[1][0]
for _ in range(10):
try:
response = self.post(
reverse('browser:mfa:manage_totp'),
{'code': self.get_topt()},
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,11 +365,9 @@ class MFALoginTest(InvenTreeAPITestCase):
response = self.post(login_url, auth_data, expected_code=401) response = self.post(login_url, auth_data, expected_code=401)
# MFA not finished - no access allowed # MFA not finished - no access allowed
self.get(reverse('api-token'), expected_code=401) self.get(reverse('api-token'), expected_code=401)
# Complete # Complete MFA (with recovery code to avoid timing issues)
self.post( self.post(
reverse('browser:mfa:authenticate'), reverse('browser:mfa:authenticate'), {'code': rc_code}, expected_code=401
{'code': self.get_topt()},
expected_code=401,
) )
self.post(reverse('browser:mfa:trust'), {'trust': False}, expected_code=200) self.post(reverse('browser:mfa:trust'), {'trust': False}, expected_code=200)
# and run through trust # and run through trust
@@ -414,15 +395,6 @@ class MFALoginTest(InvenTreeAPITestCase):
flows = response.json()['data']['flows'] flows = response.json()['data']['flows']
return next(a for a in flows if a['id'] == flow_id) return next(a for a in flows if a['id'] == flow_id)
def get_topt(self):
"""Helper to get a current totp code."""
if not self.mfa_secret:
mfa_init = self.get(reverse('browser:mfa:manage_totp'), expected_code=404)
self.mfa_secret = mfa_init.json()['meta']['secret']
return totp_auth.hotp_value(
self.mfa_secret, next(totp_auth.yield_hotp_counters_from_time())
)
class AdminTest(AdminTestCase): class AdminTest(AdminTestCase):
"""Tests for the admin interface integration.""" """Tests for the admin interface integration."""
+3 -3
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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>
)} )}
+91 -69
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More