mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-18 12:56:31 -06:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d305e10f6 | ||
|
|
02fc02ffd1 | ||
|
|
babe952bcf | ||
|
|
485e4ae178 | ||
|
|
1e6630317c | ||
|
|
9bd38aa4ba | ||
|
|
8d8dd91ae3 | ||
|
|
3c372d0c8c | ||
|
|
0dcb706f4c | ||
|
|
f8bcc3ec17 | ||
|
|
bc42450e0a | ||
|
|
fa698e7e2b | ||
|
|
c59fd55a00 | ||
|
|
486e338b0b | ||
|
|
eb32546824 | ||
|
|
cc508a544c |
13
.pkgr.yml
13
.pkgr.yml
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.9.2
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "####################################################################################"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Dummy requirements file to trigger the package pipeline
|
||||
# The backend requirements file is located in src/backend/requirements.txt
|
||||
#
|
||||
@@ -1 +0,0 @@
|
||||
python-3.10.7
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -11,6 +11,7 @@ import structlog
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import InvenTree.validators
|
||||
import common.currency
|
||||
import common.settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -8,7 +8,7 @@ import djmoney.models.validators
|
||||
|
||||
import InvenTree.fields
|
||||
import common.currency
|
||||
import common.settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:,}'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -219,7 +219,7 @@ export function useBuildOrderOutputFields({
|
||||
location: {
|
||||
value: location,
|
||||
onValueChange: (value: any) => {
|
||||
setQuantity(value);
|
||||
setLocation(value);
|
||||
}
|
||||
},
|
||||
auto_allocate: {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
7
tasks.py
7
tasks.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user