Compare commits

...

16 Commits
1.0.6 ... 1.0.x

Author SHA1 Message Date
github-actions[bot]
0d305e10f6 Fix selection list items not displaying values (#10722) (#10723)
Fixes #10701

The issue was that the table row key was using 'props.item.pk', but the
API returns 'id' instead. This caused React to not properly render the
rows with their values.

Changed the key to use 'props.item.id' with a fallback to 'props.idx'
for new rows that don't have an ID yet.


(cherry picked from commit 8d1f7f39b4)

Signed-off-by: Akhil Singh <singhakhil69@gmail.com>
Co-authored-by: Akhil Singh <35478226+akhilsingh-git@users.noreply.github.com>
2025-10-31 09:08:04 +11:00
Matthias Mair
02fc02ffd1 refactor(backend): switch to empty buildpack for package, extend supported OS versions (#10705) (#10712)
* bump vers

* fix ssl?

Added build dependencies for libbz2, libffi, and libssl.

* try empty buildpack

* clean up

* fix ref

* remove things we now do not need anymore

* add 22.04 as a target

* cleanup installer

* add changelog entry

* add dotenv

* update skript

* make task more robust for package usage

* ensure we have a site-url set

* fix style

* fix syntax

(cherry picked from commit f47a1a4675)
2025-10-29 20:12:49 +11:00
github-actions[bot]
babe952bcf Format number fix (#10710) (#10711)
* Improvements for format_number func

- Prevent accidental rendering in scientific notation

* Add multiplier argument to format_number

(cherry picked from commit c1bbef1a4d)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-29 16:43:02 +11:00
github-actions[bot]
485e4ae178 [UI] Fix BuildLineTable (#10707) (#10708)
- Closes https://github.com/inventree/InvenTree/issues/10700

(cherry picked from commit c7593d983f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-29 14:28:54 +11:00
github-actions[bot]
1e6630317c chore(deps): bump pip from 25.2 to 25.3 in /src/backend (#10690) (#10694)
* chore(deps): bump pip from 25.2 to 25.3 in /src/backend

Bumps [pip](https://github.com/pypa/pip) from 25.2 to 25.3.
- [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/pip/compare/25.2...25.3)

---
updated-dependencies:
- dependency-name: pip
  dependency-version: '25.3'
  dependency-type: indirect
...



* fix style

---------




(cherry picked from commit ceb055d61a)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-28 11:26:26 +11:00
github-actions[bot]
9bd38aa4ba Stock form fixes (#10673) (#10677)
* Improve rendering of SupplierPart in forms

- Display pack_quantity

* Ensure boolean values have default

(cherry picked from commit 636477ac13)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-26 12:00:17 +11:00
github-actions[bot]
8d8dd91ae3 Delete locations fix (#10672) (#10676)
* Cleaner handling of inputs

* Fix for frontend form:

- Fix typo in field
- Better option defaults

* Tweak part category delete form

* Add frontend tests

(cherry picked from commit 23d580c4a9)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-26 12:00:03 +11:00
Oliver
3c372d0c8c Bump software version to 1.0.9 (#10674) 2025-10-26 11:00:08 +11:00
Oliver
0dcb706f4c Default stock currency (#10641) (#10644)
* Default stock currency (#10641)

* Fix for useStockFields

- Use default currency

* Ensure default currency is observed

* Specify field default

* Improve import (for ty)

* Update migration files

- Point currency fields to the correct default method

* Unit tests

- Ensure stock item gets correct default currency

* Cleaner generation of default currency value

- Return empty string during migratoins

* Update existing migrations

* Reduce noise

* Ignore "no-matching-overload" rule for ty

* Tweak money_kwargs

* Remove conflicting code

* Fix import

* Tweak currency_code_default
2025-10-21 16:29:24 +11:00
github-actions[bot]
f8bcc3ec17 [UI] Bug fix for build output forms (#10640) (#10642)
(cherry picked from commit 2187a77153)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-21 08:22:50 +11:00
Oliver
bc42450e0a Bump software version to 1.0.8 (#10635) 2025-10-20 16:26:26 +11:00
github-actions[bot]
fa698e7e2b Fixes for SITE_URL validity checks (#10619) (#10634)
* [docker] Allow HTTPS port to be specified for Caddy proxy

* Fix naming collision for INVENTREE_WEB_PORT

* Push InvenTree version first

* Adjust Caddyfile

- Change backup server

* Fix docstring

* Tweak for site URL check:

- Ignore port if SITE_LAX_PROTOCOL_CHECK is set
- Invert logic for readability

* Additional checks for port mismatch

* Adjust middleware checks

- Allow for less strict checking of CSRF_TRUSTED_ORIGINS

* Slight refactor

(cherry picked from commit f9ce9e20b2)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-20 16:19:30 +11:00
github-actions[bot]
c59fd55a00 Setup: allow more python versions (#10615) (#10616)
* extend supported python versions

* bump max python

(cherry picked from commit 874be9920d)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-19 07:09:49 +11:00
github-actions[bot]
486e338b0b BOM updates (#10611) (#10612)
* BOM updates

- Allow variants of substititute parts to be allocated
- Closes https://github.com/inventree/InvenTree/issues/10606

* Check self.allow_variants

* Add comment

(cherry picked from commit a7c4f2adba)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-18 12:06:16 +11:00
Oliver
eb32546824 Bump software version to 1.0.7 (#10591) 2025-10-17 09:18:55 +11:00
github-actions[bot]
cc508a544c Dashboard item fix (#10596) (#10597)
* Fix for "subscribed categories" dashboard item

* Tweak filter display

* Tweak filter for "Subscribed Parts"

(cherry picked from commit 485aa6324c)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-16 23:17:11 +11:00
45 changed files with 328 additions and 181 deletions

View File

@@ -2,10 +2,8 @@ name: inventree
description: Open Source Inventory Management System
homepage: https://inventree.org
notifications: true
buildpack: https://github.com/mjmair/heroku-buildpack-python#v216-mjmair
buildpack: https://github.com/matmair/null-buildpack#master
env:
- STACK=heroku-20
- DISABLE_COLLECTSTATIC=1
- INVENTREE_DB_ENGINE=sqlite3
- INVENTREE_DB_NAME=database.sqlite3
- INVENTREE_PLUGINS_ENABLED
@@ -22,9 +20,9 @@ before:
- contrib/packager.io/before.sh
dependencies:
- curl
- "python3.9 | python3.10 | python3.11"
- "python3.9-venv | python3.10-venv | python3.11-venv"
- "python3.9-dev | python3.10-dev | python3.11-dev"
- "python3.9 | python3.10 | python3.11 | python3.12 | python3.13 | python3.14"
- "python3.9-venv | python3.10-venv | python3.11-venv | python3.12-venv | python3.13-venv | python3.14-venv"
- "python3.9-dev | python3.10-dev | python3.11-dev | python3.12-dev | python3.13-dev | python3.14-dev"
- python3-pip
- python3-cffi
- python3-brotli
@@ -38,4 +36,7 @@ dependencies:
- "libffi7 | libffi8"
targets:
ubuntu-20.04: true
ubuntu-22.04: true
ubuntu-24.04: true
debian-11: true
debian-12: true

View File

@@ -1 +0,0 @@
3.9.2

View File

@@ -30,9 +30,9 @@
}
# The default server address is configured in the .env file
# If not specified, the default address is used - http://inventree.localhost
# If not specified, the proxy listens for all http/https traffic
# If you need to listen on multiple addresses, or use a different port, you can modify this section directly
{$INVENTREE_SITE_URL:http://inventree.localhost} {
{$INVENTREE_SITE_URL:"http://, https://"} {
import log_common inventree
encode gzip

View File

@@ -101,6 +101,7 @@ services:
restart: unless-stopped
# caddy acts as reverse proxy and static file server
# You can adjust the ports that the proxy listens on via the .env file
# https://hub.docker.com/_/caddy
inventree-proxy:
container_name: inventree-proxy
@@ -109,8 +110,8 @@ services:
depends_on:
- inventree-server
ports:
- ${INVENTREE_WEB_PORT:-80}:80
- 443:443
- ${INVENTREE_HTTP_PORT:-80}:80
- ${INVENTREE_HTTPS_PORT:-443}:443
env_file:
- .env
volumes:

View File

@@ -1,9 +1,9 @@
#!/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
if [[ "${BASH_VERSINFO:-0}" -lt 4 ]]; then
printf "bash version 4 or higher is required\n" >&2
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2))); then
printf "bash version 4.2 or higher is required\n" >&2
exit 1
fi
@@ -56,17 +56,16 @@ root_command() {
get_distribution
echo "### Detected distribution: $OS $VER"
SUPPORTED=true # is this OS supported?
NEEDS_LIBSSL1_1=false # does this OS need libssl1.1?
DIST_OS=${OS,,}
DIST_VER=$VER
case "$OS" in
Ubuntu)
if [[ $VER == "24.04" ]]; then
SUPPORTED=true
if [[ $VER == "22.04" ]]; then
SUPPORTED=true
NEEDS_LIBSSL1_1=true
DIST_VER="20.04"
elif [[ $VER == "20.04" ]]; then
SUPPORTED=true
else
@@ -75,7 +74,6 @@ root_command() {
;;
"Debian GNU/Linux" | "debian gnu/linux" | Raspbian)
if [[ $VER == "12" ]]; then
DIST_VER="11"
SUPPORTED=true
elif [[ $VER == "11" ]]; then
SUPPORTED=true
@@ -111,15 +109,6 @@ root_command() {
fi
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"
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"
@@ -146,15 +135,7 @@ version_command() {
}
install.sh_usage() {
if [[ -n $long_usage ]]; then
printf "install.sh - Interactive installer for InvenTree\n"
echo
else
printf "install.sh - Interactive installer for InvenTree\n"
echo
fi
printf "install.sh - Interactive installer for InvenTree\n\n"
printf "%s\n" "Usage:"
printf " install.sh [SOURCE] [PUBLISHER] [OPTIONS]\n"
@@ -162,7 +143,7 @@ install.sh_usage() {
printf " install.sh --version | -v\n"
echo
if [[ -n $long_usage ]]; then
if [[ -n "$long_usage" ]]; then
printf "%s\n" "Options:"
printf " %s\n" "--no-call, -n"
@@ -184,13 +165,13 @@ install.sh_usage() {
printf " %s\n" "SOURCE"
printf " Package source that should be used\n"
printf " Allowed: stable, master, main\n"
printf " Default: stable\n"
printf " %s\n" "Allowed: stable, master, main"
printf " %s\n" "Default: stable"
echo
printf " %s\n" "PUBLISHER"
printf " Publisher that should be used\n"
printf " Default: inventree\n"
printf " %s\n" "Default: inventree"
echo
printf "%s\n" "Examples:"
@@ -203,11 +184,14 @@ install.sh_usage() {
}
normalize_input() {
local arg flags
local arg passthru flags
passthru=false
while [[ $# -gt 0 ]]; do
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[2]}")
elif [[ $arg =~ ^(-[a-zA-Z0-9])=(.+)$ ]]; then
@@ -218,6 +202,9 @@ normalize_input() {
for ((i = 0; i < ${#flags}; i++)); do
input+=("-${flags:i:1}")
done
elif [[ "$arg" == "--" ]]; then
passthru=true
input+=("$arg")
else
input+=("$arg")
fi
@@ -226,37 +213,11 @@ normalize_input() {
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() {
while [[ $# -gt 0 ]]; do
case "${1:-}" in
key="$1"
case "$key" in
--version | -v)
version_command
exit
@@ -301,11 +262,10 @@ parse_requirements() {
*)
if [[ -z ${args['source']+x} ]]; then
args['source']=$1
shift
elif [[ -z ${args['publisher']+x} ]]; then
elif [[ -z ${args['publisher']+x} ]]; then
args['publisher']=$1
shift
else
@@ -321,7 +281,7 @@ parse_requirements() {
[[ -n ${args['source']:-} ]] || args['source']="stable"
[[ -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
exit 1
fi
@@ -329,18 +289,19 @@ parse_requirements() {
}
initialize() {
version="2.0"
long_usage=''
declare -g version="2.0"
set -e
}
run() {
declare -A args=()
declare -A deps=()
declare -a other_args=()
declare -a input=()
declare -g long_usage=''
declare -g -A args=()
declare -g -A deps=()
declare -g -a env_var_names=()
declare -g -a input=()
normalize_input "$@"
parse_requirements "${input[@]}"
@@ -349,5 +310,6 @@ run() {
esac
}
command_line_args=("$@")
initialize
run "$@"
run "${command_line_args[@]}"

View File

@@ -46,17 +46,16 @@ echo "### Installer for InvenTree - source: $publisher/$source_url"
get_distribution
echo "### Detected distribution: $OS $VER"
SUPPORTED=true # is this OS supported?
NEEDS_LIBSSL1_1=false # does this OS need libssl1.1?
DIST_OS=${OS,,}
DIST_VER=$VER
case "$OS" in
Ubuntu)
if [[ $VER == "24.04" ]]; then
SUPPORTED=true
if [[ $VER == "22.04" ]]; then
SUPPORTED=true
NEEDS_LIBSSL1_1=true
DIST_VER="20.04"
elif [[ $VER == "20.04" ]]; then
SUPPORTED=true
else
@@ -100,15 +99,6 @@ for pkg in $REQS; do
fi
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"
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"

View File

@@ -5,7 +5,7 @@
Color_Off='\033[0m'
On_Red='\033[41m'
PYTHON_FROM=9
PYTHON_TO=12
PYTHON_TO=14
function detect_docker() {
if [ -n "$(grep docker </proc/1/cgroup)" ]; then
@@ -202,7 +202,7 @@ function detect_envs() {
export INVENTREE_DB_HOST=${INVENTREE_DB_HOST:-samplehost}
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
fi
@@ -327,7 +327,7 @@ function update_or_install() {
# Run update as app user
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 | /;'"
# Make sure permissions are correct again
@@ -407,13 +407,13 @@ function final_message() {
echo -e "${SETUP_NGINX_FILE}"
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
if ["${INVENTREE_ADMIN_USER}" ]; then
if [ -n "${INVENTREE_ADMIN_USER}" ]; then
echo -e "Admin user data:"
echo -e " Email: ${INVENTREE_ADMIN_EMAIL}"
echo -e " Username: ${INVENTREE_ADMIN_USER}"
echo -e " Password: ${INVENTREE_ADMIN_PASSWORD}"
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
echo -e "####################################################################################"
}

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
# get base info
detect_ip
detect_envs
detect_docker
detect_initcmd
detect_ip
detect_python
# Check if we are updating and need to alert

View File

@@ -5,7 +5,7 @@ title: InvenTree Installer
## Installer
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

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
#

View File

@@ -1 +0,0 @@
python-3.10.7

View File

@@ -15,6 +15,8 @@ from rest_framework.fields import URLField as RestURLField
from rest_framework.fields import empty
import InvenTree.helpers
import InvenTree.ready
from common.currency import currency_code_default
from common.settings import get_global_setting
from .validators import AllowedURLValidator, allowable_url_schemes
@@ -59,7 +61,7 @@ class InvenTreeURLField(models.URLField):
def money_kwargs(**kwargs):
"""Returns the database settings for MoneyFields."""
from common.currency import currency_code_default, currency_code_mappings
from common.currency import currency_code_mappings
# Default values (if not specified)
if 'max_digits' not in kwargs:
@@ -71,8 +73,14 @@ def money_kwargs(**kwargs):
if 'currency_choices' not in kwargs:
kwargs['currency_choices'] = currency_code_mappings()
if 'default_currency' not in kwargs:
kwargs['default_currency'] = currency_code_default()
if InvenTree.ready.isRunningMigrations():
# During migrations, avoid setting a default currency
# This prevents issues related to early evaluation of the default currency value
kwargs['default_currency'] = ''
else:
# Override default currency with a callable function
# This ensures that the default currency is always up-to-date
kwargs['default_currency'] = currency_code_default
return kwargs

View File

@@ -239,13 +239,29 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
accessed_scheme = request._current_scheme_host
referer = urlsplit(accessed_scheme)
# Ensure that the settings are set correctly with the current request
matches = (
(accessed_scheme and not accessed_scheme.startswith(settings.SITE_URL))
if not settings.SITE_LAX_PROTOCOL_CHECK
else not is_same_domain(referer.netloc, urlsplit(settings.SITE_URL).netloc)
site_url = urlsplit(settings.SITE_URL)
# Check if the accessed URL matches the SITE_URL setting
site_url_match = (
(
# Exact match on domain
is_same_domain(referer.netloc, site_url.netloc)
and referer.scheme == site_url.scheme
)
or (
# Lax protocol match, accessed URL starts with SITE_URL
settings.SITE_LAX_PROTOCOL_CHECK
and accessed_scheme.startswith(settings.SITE_URL)
)
or (
# Lax protocol match, same domain
settings.SITE_LAX_PROTOCOL_CHECK
and referer.hostname == site_url.hostname
)
)
if matches:
if not site_url_match:
# The accessed URL does not match the SITE_URL setting
if (
isinstance(settings.CSRF_TRUSTED_ORIGINS, list)
and len(settings.CSRF_TRUSTED_ORIGINS) > 1
@@ -263,17 +279,31 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
request, 'config_error.html', {'error_message': msg}, status=500
)
# Check trusted origins
if not any(
is_same_domain(referer.netloc, host)
for host in [
urlsplit(origin).netloc.lstrip('*')
trusted_origins_match = (
# Matching domain found in allowed origins
any(
is_same_domain(referer.netloc, host)
for host in [
urlsplit(origin).netloc.lstrip('*')
for origin in settings.CSRF_TRUSTED_ORIGINS
]
)
) or (
# Lax protocol match allowed
settings.SITE_LAX_PROTOCOL_CHECK
and any(
referer.hostname == urlsplit(origin).hostname
for origin in settings.CSRF_TRUSTED_ORIGINS
]
):
)
)
# Check trusted origins
if not trusted_origins_match:
msg = f'INVE-E7: The used path `{accessed_scheme}` is not in the TRUSTED_ORIGINS'
logger.error(msg)
return render(
request, 'config_error.html', {'error_message': msg}, status=500
)
# All checks passed
return None

View File

@@ -112,6 +112,15 @@ class MiddlewareTests(InvenTreeTestCase):
def test_site_lax_protocol(self):
"""Test that the site URL check is correctly working with/without lax protocol check."""
# Test that a completely different host fails
with self.settings(
SITE_URL='https://testserver', CSRF_TRUSTED_ORIGINS=['https://testserver']
):
response = self.client.get(
reverse('web'), HTTP_HOST='otherhost.example.com'
)
self.assertContains(response, 'INVE-E7: The visited path', status_code=500)
# Simple setup with proxy
with self.settings(
SITE_URL='https://testserver', CSRF_TRUSTED_ORIGINS=['https://testserver']
@@ -128,6 +137,24 @@ class MiddlewareTests(InvenTreeTestCase):
response = self.client.get(reverse('web'))
self.assertContains(response, 'INVE-E7: The visited path', status_code=500)
def test_site_url_port(self):
"""URL checks with different ports."""
with self.settings(
SITE_URL='https://testserver:8000',
CSRF_TRUSTED_ORIGINS=['https://testserver:8000'],
):
response = self.client.get(reverse('web'), HTTP_HOST='testserver:8008')
self.do_positive_test(response)
# Try again with strict protocol check
with self.settings(
SITE_URL='https://testserver:8000',
CSRF_TRUSTED_ORIGINS=['https://testserver:8000'],
SITE_LAX_PROTOCOL_CHECK=False,
):
response = self.client.get(reverse('web'), HTTP_HOST='testserver:8008')
self.assertContains(response, 'INVE-E7: The visited path', status_code=500)
def test_site_url_checks_multi(self):
"""Test that the site URL check is correctly working in a multi-site setup."""
# multi-site setup with trusted origins
@@ -149,7 +176,7 @@ class MiddlewareTests(InvenTreeTestCase):
)
self.do_positive_test(response)
# A non-trsuted origin must still fail in multi - origin setup
# A non-trusted origin must still fail in multi - origin setup
response = self.client.get(
'https://not-my-testserver.example.com/web/',
SERVER_NAME='not-my-testserver.example.com',

View File

@@ -18,7 +18,7 @@ from django.conf import settings
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = '1.0.6'
INVENTREE_SW_VERSION = '1.0.9'
logger = logging.getLogger('inventree')

View File

@@ -11,6 +11,7 @@ import structlog
from moneyed import CURRENCIES
import InvenTree.helpers
import InvenTree.ready
logger = structlog.get_logger('inventree')

View File

@@ -3,7 +3,6 @@
from django.db import migrations, connection
import djmoney.models.fields
import common.currency
import common.settings
class Migration(migrations.Migration):
@@ -17,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='supplierpricebreak',
name='price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
),
migrations.AddField(
model_name='supplierpricebreak',
name='price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3),
),
]

View File

@@ -2,7 +2,6 @@
import InvenTree.validators
import common.currency
import common.settings
from django.db import migrations, models

View File

@@ -3,7 +3,6 @@
from django.db import migrations
import djmoney.models.fields
import common.currency
import common.settings
class Migration(migrations.Migration):
@@ -17,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='purchaseorderlineitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
),
migrations.AddField(
model_name='purchaseorderlineitem',
name='purchase_price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3),
),
]

View File

@@ -2,8 +2,6 @@
from django.db import migrations
import djmoney.models.fields
import common.currency
import common.settings
class Migration(migrations.Migration):
@@ -16,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='purchaseorderlineitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='', help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
),
]

View File

@@ -2,7 +2,6 @@
from django.db import migrations
import common.currency
import common.settings
import djmoney.models.fields
@@ -16,11 +15,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='salesorderlineitem',
name='sale_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'),
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='', help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'),
),
migrations.AddField(
model_name='salesorderlineitem',
name='sale_price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3),
),
]

View File

@@ -272,13 +272,11 @@ class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
def destroy(self, request, *args, **kwargs):
"""Delete a Part category instance via the API."""
delete_parts = (
'delete_parts' in request.data and request.data['delete_parts'] == '1'
)
delete_child_categories = (
'delete_child_categories' in request.data
and request.data['delete_child_categories'] == '1'
delete_parts = str2bool(request.data.get('delete_parts', False))
delete_child_categories = str2bool(
request.data.get('delete_child_categories', False)
)
return super().destroy(
request,
*args,

View File

@@ -16,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='partsellpricebreak',
name='price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
),
migrations.AddField(
model_name='partsellpricebreak',
name='price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3),
),
]

View File

@@ -3,7 +3,6 @@
import InvenTree.fields
import django.core.validators
import common.currency
import common.settings
from django.db import migrations, models
import django.db.models.deletion
import djmoney.models.fields
@@ -21,8 +20,8 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Price break quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Quantity')),
('price_currency', djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3)),
('price', djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price')),
('price_currency', djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3)),
('price', djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price')),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='internalpricebreaks', to='part.part', verbose_name='Part')),
],
options={

View File

@@ -8,7 +8,7 @@ import djmoney.models.validators
import InvenTree.fields
import common.currency
import common.settings
class Migration(migrations.Migration):

View File

@@ -4271,6 +4271,11 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
for sub in self.substitutes.all():
parts.add(sub.part)
# Account for variants of the substitute part (if allowed)
if allow_variants and self.allow_variants:
for sub_variant in sub.part.get_descendants(include_self=False):
parts.add(sub_variant)
valid_parts = []
for p in parts:

View File

@@ -453,6 +453,7 @@ def render_html_text(text: str, **kwargs):
def format_number(
number: Union[int, float, Decimal],
decimal_places: Optional[int] = None,
multiplier: Optional[Union[int, float, Decimal]] = None,
integer: bool = False,
leading: int = 0,
separator: Optional[str] = None,
@@ -462,16 +463,20 @@ def format_number(
Arguments:
number: The number to be formatted
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
leading: Number of leading zeros (default = 0)
separator: Character to use as a thousands separator (default = None)
"""
try:
number = Decimal(str(number))
number = Decimal(str(number).strip())
except Exception:
# If the number cannot be converted to a Decimal, just return the original value
return str(number)
if multiplier is not None:
number *= Decimal(str(multiplier).strip())
if integer:
# Convert to integer
number = Decimal(int(number))
@@ -487,7 +492,13 @@ def format_number(
pass
# 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:
value = f'{value:,}'

View File

@@ -210,6 +210,14 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
"""Simple tests for number formatting tags."""
fn = report_tags.format_number
self.assertEqual(fn(None), '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.5678, decimal_places=2), '1234.57')
self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
@@ -218,6 +226,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
fn(9988776655.4321, integer=True, separator=' '), '9 988 776 655'
)
# Test with multiplier
self.assertEqual(fn(1000, multiplier=1.5), '1500')
# Failure cases
self.assertEqual(fn('abc'), 'abc')
self.assertEqual(fn(1234.456, decimal_places='a'), '1234.456')

View File

@@ -419,8 +419,12 @@ class StockLocationDetail(StockLocationMixin, CustomRetrieveUpdateDestroyAPI):
def destroy(self, request, *args, **kwargs):
"""Delete a Stock location instance via the API."""
delete_stock_items = str(request.data.get('delete_stock_items', 0)) == '1'
delete_sub_locations = str(request.data.get('delete_sub_locations', 0)) == '1'
delete_stock_items = InvenTree.helpers.str2bool(
request.data.get('delete_stock_items', False)
)
delete_sub_locations = InvenTree.helpers.str2bool(
request.data.get('delete_sub_locations', False)
)
return super().destroy(
request,

View File

@@ -16,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='stockitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
),
migrations.AddField(
model_name='stockitem',
name='purchase_price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.all_currency_codes(), default=common.currency.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.all_currency_codes(), default='', editable=False, max_length=3),
),
]

View File

@@ -2,7 +2,6 @@
from django.db import migrations
import djmoney.models.fields
import common.currency
class Migration(migrations.Migration):
@@ -15,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='stockitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='', help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
),
]

View File

@@ -6,6 +6,8 @@ from django.core.exceptions import ValidationError
from django.db.models import Sum
from django.test import override_settings
from djmoney.money import Money
from build.models import Build
from common.models import InvenTreeSetting
from company.models import Company
@@ -803,6 +805,27 @@ class StockTest(StockTestBase):
self.assertTrue(check_func())
def test_purchase_price(self):
"""Test purchase price field."""
from common.currency import currency_code_default
from common.settings import set_global_setting
part = Part.objects.filter(virtual=False).first()
for currency in ['AUD', 'USD', 'JPY']:
set_global_setting('INVENTREE_DEFAULT_CURRENCY', currency)
self.assertEqual(currency_code_default(), currency)
# Create stock item, do not specify currency - should get default
item = StockItem.objects.create(part=part, quantity=10)
self.assertEqual(item.purchase_price_currency, currency)
# Create stock item, specify currency
item = StockItem.objects.create(
part=part, quantity=10, purchase_price=Money(5, 'GBP')
)
self.assertEqual(item.purchase_price_currency, 'GBP')
class StockBarcodeTest(StockTestBase):
"""Run barcode tests for the stock app."""

View File

@@ -385,9 +385,9 @@ pdfminer-six==20250506 \
--hash=sha256:b03cc8df09cf3c7aba8246deae52e0bca7ebb112a38895b5e1d4f5dd2b8ca2e7 \
--hash=sha256:d81ad173f62e5f841b53a8ba63af1a4a355933cfc0ffabd608e568b9193909e3
# via -r src/backend/requirements-dev.in
pip==25.2 \
--hash=sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2 \
--hash=sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717
pip==25.3 \
--hash=sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343 \
--hash=sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd
# via pip-tools
pip-tools==7.5.1 \
--hash=sha256:a051a94794ba52df9acad2d7c9b0b09ae001617db458a543f8287fea7b89c2cf \

View File

@@ -24,14 +24,17 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
title: t`Subscribed Parts`,
description: t`Show the number of parts which you have subscribed to`,
modelType: ModelType.part,
params: { starred: true }
params: { starred: true, active: true }
}),
QueryCountDashboardWidget({
label: 'sub-cat',
title: t`Subscribed Categories`,
description: t`Show the number of part categories which you have subscribed to`,
modelType: ModelType.partcategory,
params: { starred: true }
params: {
starred: true,
top_level: 'none'
}
}),
QueryCountDashboardWidget({
label: 'invalid-bom',

View File

@@ -2,7 +2,7 @@ import { isTrue } from '@lib/functions/Conversion';
import type { ApiFormFieldType } from '@lib/types/Forms';
import { Switch } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
export function BooleanField({
@@ -25,6 +25,13 @@ export function BooleanField({
const { value } = field;
// Set default value if value is undefined
useEffect(() => {
if (value === undefined) {
onChange(definition.default ?? false);
}
}, [value, definition]);
// Coerce the value to a (stringified) boolean value
const booleanValue: boolean = useMemo(() => {
return isTrue(value);
@@ -33,6 +40,7 @@ export function BooleanField({
return (
<Switch
{...definition}
defaultValue={definition.default ?? false}
checked={booleanValue}
id={fieldId}
aria-label={`boolean-field-${fieldName}`}

View File

@@ -64,15 +64,22 @@ export function RenderSupplierPart(
const supplier = instance.supplier_detail ?? {};
const part = instance.part_detail ?? {};
const secondary: string = instance.SKU;
let suffix: string = part.full_name;
if (instance.pack_quantity) {
suffix += ` (${instance.pack_quantity})`;
}
return (
<RenderInlineModel
{...props}
primary={supplier?.name}
secondary={instance.SKU}
secondary={secondary}
image={
part?.thumbnail ?? part?.image ?? supplier?.thumbnail ?? supplier?.image
}
suffix={part.full_name}
suffix={suffix}
url={
props.link
? getDetailUrl(ModelType.supplierpart, instance.pk)

View File

@@ -219,7 +219,7 @@ export function useBuildOrderOutputFields({
location: {
value: location,
onValueChange: (value: any) => {
setQuantity(value);
setLocation(value);
}
},
auto_allocate: {

View File

@@ -212,7 +212,8 @@ export function useStockFields({
icon: <IconCurrencyDollar />
},
purchase_price_currency: {
icon: <IconCoins />
icon: <IconCoins />,
default: globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY')
},
packaging: {
icon: <IconPackage />

View File

@@ -1,11 +1,9 @@
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { Table } from '@mantine/core';
import { useMemo } from 'react';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
function BuildAllocateLineRow({
@@ -62,7 +60,7 @@ function BuildAllocateLineRow({
}, [props]);
return (
<Table.Tr key={`table-row-${props.item.pk}`}>
<Table.Tr key={`table-row-${props.item.id ?? props.idx}`}>
<Table.Td>
<StandaloneField fieldName='value' fieldDefinition={valueField} />
</Table.Td>

View File

@@ -183,11 +183,11 @@ export default function CategoryDetail() {
const deleteOptions = useMemo(() => {
return [
{
value: 0,
value: 'false',
display_name: t`Move items to parent category`
},
{
value: 1,
value: 'true',
display_name: t`Delete items`
}
];
@@ -202,12 +202,14 @@ export default function CategoryDetail() {
label: t`Parts Action`,
description: t`Action for parts in this category`,
choices: deleteOptions,
required: true,
field_type: 'choice'
},
delete_child_categories: {
label: t`Child Categories Action`,
description: t`Action for child categories in this category`,
choices: deleteOptions,
required: true,
field_type: 'choice'
}
},

View File

@@ -220,11 +220,11 @@ export default function Stock() {
const deleteOptions = useMemo(() => {
return [
{
value: 0,
value: 'false',
display_name: t`Move items to parent location`
},
{
value: 1,
value: 'true',
display_name: t`Delete items`
}
];
@@ -237,12 +237,14 @@ export default function Stock() {
fields: {
delete_stock_items: {
label: t`Items Action`,
required: true,
description: t`Action for stock items in this location`,
field_type: 'choice',
choices: deleteOptions
},
delete_sub_location: {
label: t`Child Locations Action`,
delete_sub_locations: {
label: t`Locations Action`,
required: true,
description: t`Action for child locations in this location`,
field_type: 'choice',
choices: deleteOptions

View File

@@ -250,7 +250,10 @@ export default function InvenTreeTableHeader({
<HoverCard
position='bottom-end'
withinPortal={true}
disabled={!tableState.filterSet.activeFilters?.length}
disabled={
hasCustomFilters ||
!tableState.filterSet.activeFilters?.length
}
>
<HoverCard.Target>
<Tooltip

View File

@@ -636,14 +636,14 @@ export default function BuildLineTable({
},
quantity: {}
},
table: table
onFormSuccess: table.refreshTable
});
const deleteAllocation = useDeleteApiFormModal({
url: ApiEndpoints.build_item_list,
pk: selectedAllocation,
title: t`Delete Stock Allocation`,
table: table
onFormSuccess: table.refreshTable
});
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
@@ -870,6 +870,8 @@ export default function BuildLineTable({
*/
const formatRecords = useCallback(
(records: any[]): any[] => {
console.log('format records:', records);
return records.map((record) => {
let allocations = [...record.allocations];

View File

@@ -54,6 +54,67 @@ test('Stock - Location Tree', async ({ browser }) => {
await page.getByRole('cell', { name: 'Factory' }).first().waitFor();
});
test('Stock - Location Delete', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'stock/location/38/sublocations'
});
// Create a sub-location
await page
.getByRole('button', { name: 'action-button-add-stock-location' })
.click();
await page
.getByRole('textbox', { name: 'text-field-name' })
.fill('my-location-1');
await page.getByRole('button', { name: 'Submit' }).click();
// Create a secondary sub-location
await loadTab(page, 'Sublocations');
await page
.getByRole('button', { name: 'action-button-add-stock-location' })
.click();
await page
.getByRole('textbox', { name: 'text-field-name' })
.fill('my-location-2');
await page.getByRole('button', { name: 'Submit' }).click();
// Navigate up to parent
await page.getByRole('link', { name: 'breadcrumb-2-my-location-1' }).click();
await loadTab(page, 'Sublocations');
await page
.getByRole('cell', { name: 'my-location-2', exact: true })
.waitFor();
// Delete this location, and all child locations
await page
.locator('div')
.filter({ hasText: /^Stock>PCB Assembler>my-location-1Stock Location$/ })
.getByLabel('action-menu-location-actions')
.click();
await page
.getByRole('menuitem', { name: 'action-menu-location-actions-delete' })
.click();
await page
.getByRole('textbox', { name: 'choice-field-delete_stock_items' })
.click();
await page
.getByRole('option', { name: 'Move items to parent location' })
.click();
await page
.getByRole('textbox', { name: 'choice-field-delete_sub_locations' })
.click();
await page.getByRole('option', { name: 'Delete items' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
// Confirm we are on the right page
await page.getByText('External PCB assembler').waitFor();
await loadTab(page, 'Sublocations');
await page.getByText('No records found').first().waitFor();
});
test('Stock - Filters', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'steven',

View File

@@ -1501,10 +1501,10 @@ Static {get_static_dir(error=False) or NOT_SPECIFIED}
Backup {get_backup_dir(error=False) or NOT_SPECIFIED}
Versions:
Python {python_version()}
Django {InvenTreeVersion.inventreeDjangoVersion()}
InvenTree {InvenTreeVersion.inventreeVersion()}
API {InvenTreeVersion.inventreeApiVersion()}
Python {python_version()}
Django {InvenTreeVersion.inventreeDjangoVersion()}
Node {node if node else NA}
Yarn {yarn if yarn else NA}
@@ -1672,7 +1672,7 @@ def frontend_download(
# if clean, delete static/web directory
if clean:
shutil.rmtree(dest_path, ignore_errors=True)
dest_path.mkdir()
dest_path.mkdir(parents=True, exist_ok=True)
info(f'Cleaned directory: {dest_path}')
# unzip build to static folder
@@ -1736,6 +1736,7 @@ def frontend_download(
# if zip file is specified, try to extract it directly
if file:
handle_extract(file)
static(c, frontend=False, skip_plugins=True)
return
# check arguments