Compare commits

...

41 Commits

Author SHA1 Message Date
Oliver
f23d405392 Update version.py (#8443)
Bump version number to 0.16.8
2024-11-07 13:29:37 +11:00
Oliver
3fe04747d7 narrw scope of cleanup (#8441) (#8442)
Co-authored-by: Matthias Mair <code@mjmair.com>
2024-11-07 13:29:28 +11:00
github-actions[bot]
8ff4eddeb9 Update docker_install.md (#8387) (#8388)
(cherry picked from commit 178f939e42)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-10-29 11:37:05 +11:00
Oliver
de2edc4ed6 Samesite fix (#8390)
* Fix for migratoin

* Fix for COOKIE_MODE

- Update to match master

* Fix default value in config template

- samesite = false, not none

* Remove conflicting migration

- Should not have back-ported this from master branch
- Will not cause any serious issues, was a "nice to have" data migration
2024-10-29 10:17:41 +11:00
github-actions[bot]
343f63c6ba [Bug] Ensure links are prepended with base URL on receipt (#8367) (#8370)
* Ensure links are prepended with base URL on receipt

* Bug fix

(cherry picked from commit 3253a4a93c)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-10-26 12:49:16 +11:00
Oliver
2bccbffe56 Update version.py (#8364)
Bump version number to 0.16.7
2024-10-26 12:19:40 +11:00
Oliver
5af0e50b79 Bug fix for attachment updating (#8362)
-  Closes https://github.com/inventree/InvenTree/issues/8354
2024-10-25 20:20:23 +11:00
github-actions[bot]
0ae9cdd39f Notifications fix (#8360) (#8361)
* Fix for app loading

- Allow collection for background worker too

* Improved logging

* Refactor MethodStorageClass

- Cache methods more intelligently
- Re-collect if null

(cherry picked from commit 331692bb46)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-10-25 18:02:43 +11:00
github-actions[bot]
7babef098a Add "active" field to SupplierPart form (#8341) (#8342)
- Previously missing from legacy interface

(cherry picked from commit 295f733ed9)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-10-23 13:07:08 +11:00
github-actions[bot]
fab846e3cc Markdown link fix (#8328) (#8329)
* Improve cleaning of markdown content

* Update unit test with new check

(cherry picked from commit cb0248d159)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-10-22 13:17:04 +11:00
github-actions[bot]
d485c6796b Add documentation about user management (#8321) (#8322)
(cherry picked from commit e219b7c914)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-10-21 10:24:59 +11:00
github-actions[bot]
5c94366bb5 Clear allocations when manually returning an item into stock from a customer (#8298) (#8300)
(cherry picked from commit 181e1dd9cc)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-10-16 18:39:11 +11:00
github-actions[bot]
cebad3d142 [CUI] Fix rendering issues for barcodes (#8286) (#8288)
- Prevent barcode data from being "escaped"
- Run through bleach to brevent malicious data injection

(cherry picked from commit b1d9a23928)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-10-15 17:28:40 +11:00
github-actions[bot]
3659bbe389 Fix bug in merge_stock_items (#8284) (#8285)
- self() -> self

(cherry picked from commit 14d92b8727)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-10-15 14:22:49 +11:00
Oliver
933a5a5595 Fix for COOKIE_MODE in settings.py (#8268)
- Backport of https://github.com/inventree/InvenTree/pull/8262
2024-10-10 09:51:48 +11:00
github-actions[bot]
6c0f6e38d0 fix part qr lable (#8255) (#8256)
(cherry picked from commit 560f57333c)

Co-authored-by: Volker <skydiablo@gmx.net>
2024-10-08 21:06:21 +11:00
Oliver
8c9a438e59 Update version.py (#8248) 2024-10-07 20:52:08 +11:00
Oliver
6e37f0cd8b Markdown xss backport (#8244)
* Update helpers.py

* Update mixins.py

* format

* format

* Allow horizontal rule in markdown

* More instructive error msg

* Specify output_format to markdown.markdown

Ref: https://python-markdown.github.io/reference/markdown/serializers/

* Cleanup

* Adjust allowable markdown tags

* Add unit test for malicious markdown XSS

* Allow <pre> tag

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
2024-10-07 20:03:39 +11:00
github-actions[bot]
1c6d25ce33 Fix build item over-allocation checks (#8235) (#8241)
(cherry picked from commit a1024f1a67)

Co-authored-by: Dean <me@dgardiner.net>
2024-10-06 21:13:27 +11:00
Oliver
86111ad9b9 Merge commit from fork (#8229)
* Sanitize markdown when rendering notes fields

* Revert "Sanitize markdown when rendering notes fields"

This reverts commit 373ebfbef0ce25fa58cb4e635e99f35b5e4df07c.

* Mask error message when downloading image
2024-10-02 14:28:13 +10:00
Oliver
524e6ddf79 Update version.py (#8214)
Bump version number to 0.16.5
2024-09-30 10:36:24 +10:00
github-actions[bot]
83be1b8a0f Path management improvements (#8210) (#8212)
- Improve path resolution for backup and restore commands
- Closes https://github.com/inventree/InvenTree/issues/8207

(cherry picked from commit 73a3e504a9)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-09-30 10:34:38 +10:00
github-actions[bot]
974c2737af Add exception handling for default template creatoin (#8209) (#8211)
(cherry picked from commit a71754b086)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-09-30 10:19:36 +10:00
github-actions[bot]
0a0da7b65b Exception handling for BulkDeleteMixin (#8205) (#8206)
* Exception handling for BulkDeleteMixin

* Fix unit test

(cherry picked from commit 33499d61bd)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-09-29 15:38:28 +10:00
Oliver
b12bd3bb4b Ensure that stock item trees are rebuilt correctly after serialization (#8193)
- No idea how this has not been detected previously
2024-09-26 22:54:45 +10:00
Oliver
83be3cfa71 Increase timeout for report printing (#8187) 2024-09-26 09:48:37 +10:00
github-actions[bot]
fda47ff6ee Fix typo (#8181) (#8182)
direction -> directory

(cherry picked from commit 0faa507a14)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-09-25 12:07:45 +10:00
github-actions[bot]
69676f308b Remove translation for logged warnings (#8173) (#8176)
(cherry picked from commit 8928bc127a)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-09-24 21:52:31 +10:00
github-actions[bot]
178e3313f9 Enhance exception management (#8174) (#8175)
(cherry picked from commit 6d0353028f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-09-24 21:52:03 +10:00
Oliver
b0353fafbf Update version.py (#8158)
Bump version number to 0.16.4
2024-09-21 18:29:33 +10:00
github-actions[bot]
b0b05e479a Ensure DBBACKUP_STORAGE_OPTIONS is a dict (#8153) (#8154)
(cherry picked from commit 0cd493e96e)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-09-21 07:34:55 +10:00
Oliver
003ea5de71 Improve logic for automatically updating part pricing (#8090) (#8122)
* Improve logic for automatically updating part pricing

* Simplify logic

* Update unit tests to ensure pricing flows upwards

* Unit test update

* Add unit tests for handling of "multi level" BOM pricing

* ADjust unit tests

* Improve efficiency of operation

* Adjust testing for pricing

- Only allow pricing updates in testing if TESTING_PRICING flag is set

* Tweak when pricing updates are performed

* More tweaks
2024-09-13 14:31:47 +10:00
github-actions[bot]
4e8c59cf2a Fix image upload for company page (#8120) (#8123)
- Fixes https://github.com/inventree/InvenTree/issues/8111

(cherry picked from commit d4e67c0f03)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-09-13 14:17:09 +10:00
Oliver
f42146bc0a Update version.py (#8121)
Bump version to 0.16.3
2024-09-13 11:34:34 +10:00
Matthias Mair
065aca46cd use uv for install (#8082) (#8095)
* use uv for install (#8082)

(cherry picked from commit 360171fac5)

# Conflicts:
#	contrib/packager.io/functions.sh

* Update mlc_config.json
2024-09-10 10:17:42 +10:00
Matthias Mair
89be7f2699 [0.16.x] Packager: easier to parse debug messages (#8083) (#8096)
* Packager: easier to parse debug messages (#8083)

* Add new version to logging

* Add easier to search logging

* shorten appended string

* make migrations that run on fresh installs easier to read

(cherry picked from commit 3d9db2543d)

# Conflicts:
#	contrib/packager.io/functions.sh
#	contrib/packager.io/preinstall.sh

* Update contributing.md
2024-09-09 20:17:13 +10:00
Oliver
d46505671f Update version.py (#8036)
Bump version number to 0.16.2
2024-08-30 10:09:31 +10:00
github-actions[bot]
4c15e5c943 Fix package upgrade process (#8034) (#8035)
* ensure site_url is preserved

* check if clear-generated is available

(cherry picked from commit 3951b3f56e)

Co-authored-by: Matthias Mair <code@mjmair.com>
2024-08-30 09:15:40 +10:00
github-actions[bot]
e89cfd4958 Add extra undefined check for table filters (#8008) (#8014)
* Add extra undefined check for table filters

* Logic fix..

- Do not return early, this is mistake!
- Correctly handle empty activeFilter state

(cherry picked from commit 7d844e02be)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-08-28 09:34:26 +10:00
github-actions[bot]
edf02cd817 Hide "build orders" tab for inactive parts (#7992) (#7993)
- Otherwise results in a 400 error

(cherry picked from commit 881220cdb3)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2024-08-26 09:41:59 +10:00
Oliver
92871bf000 Update version.py (#7991)
Bump version number to 0.16.1
2024-08-26 09:40:33 +10:00
52 changed files with 760 additions and 251 deletions

View File

@@ -13,6 +13,7 @@ function detect_docker() {
else
DOCKER="no"
fi
echo "# POI04| Running in docker: ${DOCKER}"
}
function detect_initcmd() {
@@ -30,6 +31,7 @@ function detect_initcmd() {
if [ "${DOCKER}" == "yes" ]; then
INIT_CMD="initctl"
fi
echo "# POI05| Using init command: ${INIT_CMD}"
}
function detect_ip() {
@@ -37,36 +39,36 @@ function detect_ip() {
if [ "${SETUP_NO_CALLS}" == "true" ]; then
# Use local IP address
echo "# Getting the IP address of the first local IP address"
echo "# POI06| Getting the IP address of the first local IP address"
export INVENTREE_IP=$(hostname -I | awk '{print $1}')
else
# Use web service to get the IP address
echo "# Getting the IP address of the server via web service"
echo "# POI06| Getting the IP address of the server via web service"
export INVENTREE_IP=$(curl -s https://checkip.amazonaws.com)
fi
echo "IP address is ${INVENTREE_IP}"
echo "# POI06| IP address is ${INVENTREE_IP}"
}
function detect_python() {
# Detect if there is already a python version installed in /opt/inventree/env/lib
if test -f "${APP_HOME}/env/bin/python"; then
echo "# Python environment already present"
echo "# POI07| Python environment already present"
# Extract earliest python version initialised from /opt/inventree/env/lib
SETUP_PYTHON=$(ls -1 ${APP_HOME}/env/bin/python* | sort | head -n 1)
echo "# Found earlier used version: ${SETUP_PYTHON}"
echo "# POI07| Found earlier used version: ${SETUP_PYTHON}"
else
echo "# No python environment found - using environment variable: ${SETUP_PYTHON}"
echo "# POI07| No python environment found - using environment variable: ${SETUP_PYTHON}"
fi
# Try to detect a python between 3.9 and 3.12 in reverse order
if [ -z "$(which ${SETUP_PYTHON})" ]; then
echo "# Trying to detecting python3.${PYTHON_FROM} to python3.${PYTHON_TO} - using newest version"
echo "# POI07| Trying to detecting python3.${PYTHON_FROM} to python3.${PYTHON_TO} - using newest version"
for i in $(seq $PYTHON_TO -1 $PYTHON_FROM); do
echo "# Checking for python3.${i}"
echo "# POI07| Checking for python3.${i}"
if [ -n "$(which python3.${i})" ]; then
SETUP_PYTHON="python3.${i}"
echo "# Found python3.${i} installed - using for setup ${SETUP_PYTHON}"
echo "# POI07| Found python3.${i} installed - using for setup ${SETUP_PYTHON}"
break
fi
done
@@ -75,12 +77,14 @@ function detect_python() {
# Ensure python can be executed - abort if not
if [ -z "$(which ${SETUP_PYTHON})" ]; then
echo "${On_Red}"
echo "# Python ${SETUP_PYTHON} not found - aborting!"
echo "# Please ensure python can be executed with the command '$SETUP_PYTHON' by the current user '$USER'."
echo "# If you are using a different python version, please set the environment variable SETUP_PYTHON to the correct command - eg. 'python3.10'."
echo "# POI07| Python ${SETUP_PYTHON} not found - aborting!"
echo "# POI07| Please ensure python can be executed with the command '$SETUP_PYTHON' by the current user '$USER'."
echo "# POI07| If you are using a different python version, please set the environment variable SETUP_PYTHON to the correct command - eg. 'python3.10'."
echo "${Color_Off}"
exit 1
fi
echo "# POI07| Using python command: ${SETUP_PYTHON}"
}
function get_env() {
@@ -95,7 +99,7 @@ function get_env() {
done
if [ -n "${SETUP_DEBUG}" ]; then
echo "Done getting env $envname: ${!envname}"
echo "# POI02| Done getting env $envname: ${!envname}"
fi
}
@@ -103,7 +107,7 @@ function detect_local_env() {
# Get all possible envs for the install
if [ -n "${SETUP_DEBUG}" ]; then
echo "# Printing local envs - before #++#"
echo "# POI02| Printing local envs - before #++#"
printenv
fi
@@ -113,7 +117,7 @@ function detect_local_env() {
done
if [ -n "${SETUP_DEBUG}" ]; then
echo "# Printing local envs - after #++#"
echo "# POI02| Printing local envs - after #++#"
printenv
fi
}
@@ -121,15 +125,17 @@ function detect_local_env() {
function detect_envs() {
# Detect all envs that should be passed to setup commands
echo "# Setting base environment variables"
echo "# POI03| Setting base environment variables"
export INVENTREE_CONFIG_FILE=${INVENTREE_CONFIG_FILE:-${CONF_DIR}/config.yaml}
if test -f "${INVENTREE_CONFIG_FILE}"; then
echo "# Using existing config file: ${INVENTREE_CONFIG_FILE}"
echo "# POI03| Using existing config file: ${INVENTREE_CONFIG_FILE}"
# Install parser
echo "# POI03| Installing requirements"
pip install --require-hashes -r ${APP_HOME}/contrib/dev_reqs/requirements.txt -q
echo "# POI03| Installed requirements"
# Load config
export INVENTREE_CONF_DATA=$(cat ${INVENTREE_CONFIG_FILE} | jc --yaml)
@@ -149,10 +155,10 @@ function detect_envs() {
export INVENTREE_DB_HOST=$(jq -r '.[].database.HOST' <<< ${INVENTREE_CONF_DATA})
export INVENTREE_DB_PORT=$(jq -r '.[].database.PORT' <<< ${INVENTREE_CONF_DATA})
else
echo "# No config file found: ${INVENTREE_CONFIG_FILE}, using envs or defaults"
echo "# POI03| No config file found: ${INVENTREE_CONFIG_FILE}, using envs or defaults"
if [ -n "${SETUP_DEBUG}" ]; then
echo "# Print current envs"
echo "# POI03| Print current envs"
printenv | grep INVENTREE_
printenv | grep SETUP_
fi
@@ -175,43 +181,43 @@ function detect_envs() {
fi
# For debugging pass out the envs
echo "# Collected environment variables:"
echo "# INVENTREE_MEDIA_ROOT=${INVENTREE_MEDIA_ROOT}"
echo "# INVENTREE_STATIC_ROOT=${INVENTREE_STATIC_ROOT}"
echo "# INVENTREE_BACKUP_DIR=${INVENTREE_BACKUP_DIR}"
echo "# INVENTREE_PLUGINS_ENABLED=${INVENTREE_PLUGINS_ENABLED}"
echo "# INVENTREE_PLUGIN_FILE=${INVENTREE_PLUGIN_FILE}"
echo "# INVENTREE_SECRET_KEY_FILE=${INVENTREE_SECRET_KEY_FILE}"
echo "# INVENTREE_DB_ENGINE=${INVENTREE_DB_ENGINE}"
echo "# INVENTREE_DB_NAME=${INVENTREE_DB_NAME}"
echo "# INVENTREE_DB_USER=${INVENTREE_DB_USER}"
echo "# POI03| Collected environment variables:"
echo "# POI03| INVENTREE_MEDIA_ROOT=${INVENTREE_MEDIA_ROOT}"
echo "# POI03| INVENTREE_STATIC_ROOT=${INVENTREE_STATIC_ROOT}"
echo "# POI03| INVENTREE_BACKUP_DIR=${INVENTREE_BACKUP_DIR}"
echo "# POI03| INVENTREE_PLUGINS_ENABLED=${INVENTREE_PLUGINS_ENABLED}"
echo "# POI03| INVENTREE_PLUGIN_FILE=${INVENTREE_PLUGIN_FILE}"
echo "# POI03| INVENTREE_SECRET_KEY_FILE=${INVENTREE_SECRET_KEY_FILE}"
echo "# POI03| INVENTREE_DB_ENGINE=${INVENTREE_DB_ENGINE}"
echo "# POI03| INVENTREE_DB_NAME=${INVENTREE_DB_NAME}"
echo "# POI03| INVENTREE_DB_USER=${INVENTREE_DB_USER}"
if [ -n "${SETUP_DEBUG}" ]; then
echo "# INVENTREE_DB_PASSWORD=${INVENTREE_DB_PASSWORD}"
echo "# POI03| INVENTREE_DB_PASSWORD=${INVENTREE_DB_PASSWORD}"
fi
echo "# INVENTREE_DB_HOST=${INVENTREE_DB_HOST}"
echo "# INVENTREE_DB_PORT=${INVENTREE_DB_PORT}"
echo "# POI03| INVENTREE_DB_HOST=${INVENTREE_DB_HOST}"
echo "# POI03| INVENTREE_DB_PORT=${INVENTREE_DB_PORT}"
}
function create_initscripts() {
# Make sure python env exists
if test -f "${APP_HOME}/env"; then
echo "# python environment already present - skipping"
echo "# POI09| python environment already present - skipping"
else
echo "# Setting up python environment"
echo "# POI09| Setting up python environment"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && ${SETUP_PYTHON} -m venv env"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && env/bin/pip install invoke wheel"
# Check INSTALLER_EXTRA exists and load it
if test -f "${APP_HOME}/INSTALLER_EXTRA"; then
echo "# Loading extra packages from INSTALLER_EXTRA"
echo "# POI09| Loading extra packages from INSTALLER_EXTRA"
source ${APP_HOME}/INSTALLER_EXTRA
fi
if [ -n "${SETUP_EXTRA_PIP}" ]; then
echo "# Installing extra pip packages"
echo "# POI09| Installing extra pip packages"
if [ -n "${SETUP_DEBUG}" ]; then
echo "# Extra pip packages: ${SETUP_EXTRA_PIP}"
echo "# POI09| Extra pip packages: ${SETUP_EXTRA_PIP}"
fi
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && env/bin/pip install ${SETUP_EXTRA_PIP}"
# Write extra packages to INSTALLER_EXTRA
@@ -221,41 +227,45 @@ function create_initscripts() {
# Unlink default config if it exists
if test -f "/etc/nginx/sites-enabled/default"; then
echo "# Unlinking default nginx config\n# Old file still in /etc/nginx/sites-available/default"
echo "# POI09| Unlinking default nginx config\n# POI09| Old file still in /etc/nginx/sites-available/default"
sudo unlink /etc/nginx/sites-enabled/default
echo "# POI09| Unlinked default nginx config"
fi
# Create InvenTree specific nginx config
echo "# Stopping nginx"
echo "# POI09| Stopping nginx"
${INIT_CMD} stop nginx
echo "# Setting up nginx to ${SETUP_NGINX_FILE}"
echo "# POI09| Stopped nginx"
echo "# POI09| Setting up nginx to ${SETUP_NGINX_FILE}"
# Always use the latest nginx config; important if new headers are added / needed for security
cp ${APP_HOME}/contrib/packager.io/nginx.prod.conf ${SETUP_NGINX_FILE}
sed -i s/inventree-server:8000/localhost:6000/g ${SETUP_NGINX_FILE}
sed -i s=var/www=opt/inventree/data=g ${SETUP_NGINX_FILE}
# Start nginx
echo "# Starting nginx"
echo "# POI09| Starting nginx"
${INIT_CMD} start nginx
echo "# POI09| Started nginx"
echo "# (Re)creating init scripts"
echo "# POI09| (Re)creating init scripts"
# This resets scale parameters to a known state
inventree scale web="1" worker="1"
echo "# Enabling InvenTree on boot"
echo "# POI09| Enabling InvenTree on boot"
${INIT_CMD} enable inventree
echo "# POI09| Enabled InvenTree on boot"
}
function create_admin() {
# Create data for admin users - stop with setting SETUP_ADMIN_NOCREATION to true
if [ "${SETUP_ADMIN_NOCREATION}" == "true" ]; then
echo "# Admin creation is disabled - skipping"
echo "# POI10| Admin creation is disabled - skipping"
return
fi
if test -f "${SETUP_ADMIN_PASSWORD_FILE}"; then
echo "# Admin data already exists - skipping"
echo "# POI10| Admin data already exists - skipping"
else
echo "# Creating admin user data"
echo "# POI10| Creating admin user data"
# Static admin data
export INVENTREE_ADMIN_USER=${INVENTREE_ADMIN_USER:-admin}
@@ -270,13 +280,15 @@ function create_admin() {
}
function start_inventree() {
echo "# Starting InvenTree"
echo "# POI15| Starting InvenTree"
${INIT_CMD} start inventree
echo "# POI15| Started InvenTree"
}
function stop_inventree() {
echo "# Stopping InvenTree"
echo "# POI11| Stopping InvenTree"
${INIT_CMD} stop inventree
echo "# POI11| Stopped InvenTree"
}
function update_or_install() {
@@ -285,23 +297,23 @@ function update_or_install() {
chown ${APP_USER}:${APP_GROUP} ${APP_HOME} -R
# Run update as app user
echo "# Updating InvenTree"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && pip install wheel"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke update | sed -e 's/^/# inv update| /;'"
echo "# POI12| Updating InvenTree"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && pip install uv wheel"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke update --uv | sed -e 's/^/# POI12| u | /;'"
# Make sure permissions are correct again
echo "# Set permissions for data dir and media: ${DATA_DIR}"
echo "# POI12| Set permissions for data dir and media: ${DATA_DIR}"
chown ${APP_USER}:${APP_GROUP} ${DATA_DIR} -R
chown ${APP_USER}:${APP_GROUP} ${CONF_DIR} -R
}
function set_env() {
echo "# Setting up InvenTree config values"
echo "# POI13| Setting up InvenTree config values"
inventree config:set INVENTREE_CONFIG_FILE=${INVENTREE_CONFIG_FILE}
# Changing the config file
echo "# Writing the settings to the config file ${INVENTREE_CONFIG_FILE}"
echo "# POI13| Writing the settings to the config file ${INVENTREE_CONFIG_FILE}"
# Media Root
sed -i s=#media_root:\ \'/home/inventree/data/media\'=media_root:\ \'${INVENTREE_MEDIA_ROOT}\'=g ${INVENTREE_CONFIG_FILE}
# Static Root
@@ -332,23 +344,28 @@ function set_env() {
# Fixing the permissions
chown ${APP_USER}:${APP_GROUP} ${DATA_DIR} ${INVENTREE_CONFIG_FILE}
echo "# POI13| Done setting up InvenTree config values"
}
function set_site() {
# Ensure IP is known
if [ -z "${INVENTREE_IP}" ]; then
echo "# No IP address found - skipping"
echo "# POI14| No IP address found - skipping"
return
fi
# Check if INVENTREE_SITE_URL in inventree config
if [ -z "$(inventree config:get INVENTREE_SITE_URL)" ]; then
echo "# Setting up InvenTree site URL"
echo "# POI14| Setting up InvenTree site URL"
inventree config:set INVENTREE_SITE_URL=http://${INVENTREE_IP}
else
echo "# POI14| Site URL already set - skipping"
fi
}
function final_message() {
echo "# POI16| Printing Final message"
echo -e "####################################################################################"
echo -e "This InvenTree install uses nginx, the settings for the webserver can be found in"
echo -e "${SETUP_NGINX_FILE}"
@@ -362,10 +379,12 @@ function final_message() {
function update_checks() {
echo "# Running upgrade"
echo "# POI08| Running upgrade"
local old_version=$1
local old_version_rev=$(echo ${old_version} | cut -d'-' -f1 | cut -d'.' -f2)
echo "# Old version is: ${old_version} - release: ${old_version_rev}"
local new_version=$(dpkg-query --show --showformat='${Version}' inventree)
local new_version_rev=$(echo ${new_version} | cut -d'-' -f1 | cut -d'.' -f2)
echo "# POI08| Old version is: ${old_version} | ${old_version_rev} - updating to ${new_version} | ${old_version_rev}"
local ABORT=false
function check_config_value() {
@@ -378,25 +397,25 @@ function update_checks() {
value=$(jq -r ".[].${config_key}" <<< ${INVENTREE_CONF_DATA})
fi
if [ -z "${value}" ] || [ "$value" == "null" ]; then
echo "# No setting for ${name} found - please set it manually either in ${INVENTREE_CONFIG_FILE} under '${config_key}' or with 'inventree config:set ${env_key}=value'"
echo "# POI08| No setting for ${name} found - please set it manually either in ${INVENTREE_CONFIG_FILE} under '${config_key}' or with 'inventree config:set ${env_key}=value'"
ABORT=true
else
echo "# Found setting for ${name} - ${value}"
echo "# POI08| Found setting for ${name} - ${value}"
fi
}
# Custom checks if old version is below 0.8.0
if [ "${old_version_rev}" -lt "9" ]; then
echo "# Old version is below 0.9.0 - You might be missing some configs"
echo "# POI08| Old version is below 0.9.0 - You might be missing some configs"
# Check for BACKUP_DIR and SITE_URL in INVENTREE_CONF_DATA and config
check_config_value "INVENTREE_SITE_URL" "site_url" "site URL"
check_config_value "INVENTREE_BACKUP_DIR" "backup_dir" "backup dir"
if [ "${ABORT}" = true ]; then
echo "# Aborting - please set the missing values and run the update again"
echo "# POI08| Aborting - please set the missing values and run the update again"
exit 1
fi
echo "# All checks passed - continuing with the update"
echo "# POI08| All checks passed - continuing with the update"
fi
}

View File

@@ -3,15 +3,18 @@
# packager.io postinstall script
#
echo "# POI01| Running postinstall script - start - $(date)"
exec > >(tee ${APP_HOME}/log/setup_$(date +"%F_%H_%M_%S").log) 2>&1
PATH=${APP_HOME}/env/bin:${APP_HOME}/:/sbin:/bin:/usr/sbin:/usr/bin:
# import functions
echo "# POI01| Importing functions"
. ${APP_HOME}/contrib/packager.io/functions.sh
echo "# POI01| Functions imported"
# Envs that should be passed to setup commands
export SETUP_ENVS=PATH,APP_HOME,INVENTREE_MEDIA_ROOT,INVENTREE_STATIC_ROOT,INVENTREE_BACKUP_DIR,INVENTREE_PLUGINS_ENABLED,INVENTREE_PLUGIN_FILE,INVENTREE_CONFIG_FILE,INVENTREE_SECRET_KEY_FILE,INVENTREE_DB_ENGINE,INVENTREE_DB_NAME,INVENTREE_DB_USER,INVENTREE_DB_PASSWORD,INVENTREE_DB_HOST,INVENTREE_DB_PORT,INVENTREE_ADMIN_USER,INVENTREE_ADMIN_EMAIL,INVENTREE_ADMIN_PASSWORD,SETUP_NGINX_FILE,SETUP_ADMIN_PASSWORD_FILE,SETUP_NO_CALLS,SETUP_DEBUG,SETUP_EXTRA_PIP,SETUP_PYTHON,SETUP_ADMIN_NOCREATION
export SETUP_ENVS=PATH,APP_HOME,INVENTREE_MEDIA_ROOT,INVENTREE_STATIC_ROOT,INVENTREE_BACKUP_DIR,INVENTREE_SITE_URL,INVENTREE_PLUGINS_ENABLED,INVENTREE_PLUGIN_FILE,INVENTREE_CONFIG_FILE,INVENTREE_SECRET_KEY_FILE,INVENTREE_DB_ENGINE,INVENTREE_DB_NAME,INVENTREE_DB_USER,INVENTREE_DB_PASSWORD,INVENTREE_DB_HOST,INVENTREE_DB_PORT,INVENTREE_ADMIN_USER,INVENTREE_ADMIN_EMAIL,INVENTREE_ADMIN_PASSWORD,SETUP_NGINX_FILE,SETUP_ADMIN_PASSWORD_FILE,SETUP_NO_CALLS,SETUP_DEBUG,SETUP_EXTRA_PIP,SETUP_PYTHON,SETUP_ADMIN_NOCREATION
# Get the envs
detect_local_env
@@ -37,9 +40,9 @@ detect_ip
detect_python
# Check if we are updating and need to alert
echo "# Checking if update checks are needed"
echo "# POI08| Checking if update checks are needed"
if [ -z "$2" ]; then
echo "# Normal install - no need for checks"
echo "# POI08| Normal install - no need for checks"
else
update_checks $2
fi
@@ -60,3 +63,4 @@ start_inventree
# show info
final_message
echo "# POI17| Running postinstall script - done - $(date)"

View File

@@ -2,14 +2,22 @@
#
# packager.io preinstall/preremove script
#
echo "# PRI01| Running preinstall script - start - $(date)"
PATH=${APP_HOME}/env/bin:${APP_HOME}/:/sbin:/bin:/usr/sbin:/usr/bin:
# Envs that should be passed to setup commands
export SETUP_ENVS=PATH,APP_HOME,INVENTREE_MEDIA_ROOT,INVENTREE_STATIC_ROOT,INVENTREE_BACKUP_DIR,INVENTREE_PLUGINS_ENABLED,INVENTREE_PLUGIN_FILE,INVENTREE_CONFIG_FILE,INVENTREE_SECRET_KEY_FILE,INVENTREE_DB_ENGINE,INVENTREE_DB_NAME,INVENTREE_DB_USER,INVENTREE_DB_PASSWORD,INVENTREE_DB_HOST,INVENTREE_DB_PORT,INVENTREE_ADMIN_USER,INVENTREE_ADMIN_EMAIL,INVENTREE_ADMIN_PASSWORD,SETUP_NGINX_FILE,SETUP_ADMIN_PASSWORD_FILE,SETUP_NO_CALLS,SETUP_DEBUG,SETUP_EXTRA_PIP,SETUP_PYTHON
if test -f "${APP_HOME}/env/bin/pip"; then
echo "# Clearing precompiled files"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke clear-generated"
# Check if clear-generated is available
if sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke int.clear-generated --help" > /dev/null 2>&1; then
echo "# PRI02| Clearing precompiled files"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke int.clear-generated"
else
echo "# PRI02| Clearing precompiled files - skipping"
fi
else
echo "# No python environment found - skipping"
echo "# PRI02| No python environment found - skipping"
fi
echo "# PRI03| Running preinstall script - done - $(date)"

View File

@@ -52,7 +52,7 @@ invoke setup-dev
## Branches and Versioning
InvenTree roughly follow the [GitLab flow](https://docs.gitlab.com/ee/topics/gitlab_flow.html) branching style, to allow simple management of multiple tagged releases, short-lived branches, and development on the main branch.
InvenTree roughly follow the [GitLab flow](https://about.gitlab.com/topics/version-control/what-are-gitlab-flow-best-practices/) branching style, to allow simple management of multiple tagged releases, short-lived branches, and development on the main branch.
There are nominally 5 active branches:
- `master` - The main development branch

View File

@@ -0,0 +1,49 @@
---
title: Account Management
---
## User Accounts
By default, InvenTree does not ship with any user accounts. Configuring user accounts is the first step to login to the InvenTree server.
### Administrator Account
You can configure InvenTree to create an administrator account on the first run. This account will have full *superuser* access to the InvenTree server.
This account is created when you first run the InvenTree server instance. The username / password for this account can be configured in the configuration file, or environment variables.
!!! info "More Information"
For more information on configuring the administrator account, refer to the [configuration documentation](./config.md#administrator-account).
### Create Superuser
Another way to create an administrator account is to use the `superuser` command. This will create a new superuser account with the specified username and password.
```bash
invoke superuser
```
Or, if you are running InvenTree in a Docker container:
```bash
docker exec -rm -it inventree-server invoke superuser
```
### User Management
Once you have created an administrator account, you can create and manage additional user accounts from the InvenTree web interface.
## Password Management
### Reset Password via Command Line
If a password has been lost, and other backup options (such as email recovery) are unavailable, the system administrator can reset the password for a user account from the command line.
Log into the machine running the InvenTree server, and run the following command (from the top-level source directory):
```bash
cd src/backend/InvenTree
python ./manage.py changepassword <username>
```
The system will prompt you to enter a new password for the specified user account.

View File

@@ -37,8 +37,11 @@ The following files required for this setup are provided with the InvenTree sour
Download these files to a directory on your local machine.
!!! warning "File Extensions"
If your computer adds *.txt* extensions to any of the downloaded files, rename the file and remove the added extension before continuing!
!!! success "Working Directory"
This tutorial assumes you are working from a direction where all of these files are located.
This tutorial assumes you are working from a directory where all of these files are located.
!!! tip "No Source Required"
For a production setup you do not need the InvenTree source code. Simply download the three required files from the links above!

View File

@@ -97,6 +97,7 @@ nav:
- Production: start/bare_prod.md
- Development: start/bare_dev.md
- Serving Files: start/serving_files.md
- User Accounts: start/accounts.md
- Data Backup: start/backup.md
- Migrating Data: start/migrate.md
- Advanced Topics: start/advanced.md

View File

@@ -6,6 +6,9 @@
{
"pattern": "http://localhost"
},
{
"pattern": "https://localhost:5173/"
},
{
"pattern": "http://127.0.0.1"
},

View File

@@ -383,11 +383,26 @@ class BulkDeleteMixin:
# Filter by provided item ID values
if items:
queryset = queryset.filter(id__in=items)
try:
queryset = queryset.filter(id__in=items)
except Exception:
raise ValidationError({
'non_field_errors': _('Invalid items list provided')
})
# Filter by provided filters
if filters:
queryset = queryset.filter(**filters)
try:
queryset = queryset.filter(**filters)
except Exception:
raise ValidationError({
'non_field_errors': _('Invalid filters provided')
})
if queryset.count() == 0:
raise ValidationError({
'non_field_errors': _('No items found to delete')
})
# Run a final validation step (should raise an error if the deletion should not proceed)
self.validate_delete(queryset, request)

View File

@@ -40,9 +40,14 @@ class InvenTreeConfig(AppConfig):
- Adding users set in the current environment
"""
# skip loading if plugin registry is not loaded or we run in a background thread
if not InvenTree.ready.isPluginRegistryLoaded():
return
# Skip if not in worker or main thread
if (
not InvenTree.ready.isPluginRegistryLoaded()
or not InvenTree.ready.isInMainThread()
not InvenTree.ready.isInMainThread()
and not InvenTree.ready.isInWorkerThread()
):
return
@@ -52,7 +57,6 @@ class InvenTreeConfig(AppConfig):
if InvenTree.ready.canAppAccessDatabase() or settings.TESTING_ENV:
self.remove_obsolete_tasks()
self.collect_tasks()
self.start_background_tasks()

View File

@@ -21,6 +21,7 @@ from django.core.files.storage import Storage, default_storage
from django.http import StreamingHttpResponse
from django.utils.translation import gettext_lazy as _
import bleach
import pytz
import regex
from bleach import clean
@@ -810,6 +811,65 @@ def remove_non_printable_characters(
return cleaned
def clean_markdown(value: str):
"""Clean a markdown string.
This function will remove javascript and other potentially harmful content from the markdown string.
"""
import markdown
try:
markdownify_settings = settings.MARKDOWNIFY['default']
except (AttributeError, KeyError):
markdownify_settings = {}
extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', [])
extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {})
# Generate raw HTML from provided markdown (without sanitizing)
# Note: The 'html' output_format is required to generate self closing tags, e.g. <tag> instead of <tag />
html = markdown.markdown(
value or '',
extensions=extensions,
extension_configs=extension_configs,
output_format='html',
)
# Bleach settings
whitelist_tags = markdownify_settings.get(
'WHITELIST_TAGS', bleach.sanitizer.ALLOWED_TAGS
)
whitelist_attrs = markdownify_settings.get(
'WHITELIST_ATTRS', bleach.sanitizer.ALLOWED_ATTRIBUTES
)
whitelist_styles = markdownify_settings.get(
'WHITELIST_STYLES', bleach.css_sanitizer.ALLOWED_CSS_PROPERTIES
)
whitelist_protocols = markdownify_settings.get(
'WHITELIST_PROTOCOLS', bleach.sanitizer.ALLOWED_PROTOCOLS
)
strip = markdownify_settings.get('STRIP', True)
css_sanitizer = bleach.css_sanitizer.CSSSanitizer(
allowed_css_properties=whitelist_styles
)
cleaner = bleach.Cleaner(
tags=whitelist_tags,
attributes=whitelist_attrs,
css_sanitizer=css_sanitizer,
protocols=whitelist_protocols,
strip=strip,
)
# Clean the HTML content (for comparison). This must be the same as the original content
clean_html = cleaner.clean(html)
if html != clean_html:
raise ValidationError(_('Data contains prohibited markdown content'))
return value
def hash_barcode(barcode_data):
"""Calculate a 'unique' hash for a barcode string.

View File

@@ -6,7 +6,11 @@ from rest_framework import generics, mixins, status
from rest_framework.response import Response
from InvenTree.fields import InvenTreeNotesField
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
from InvenTree.helpers import (
clean_markdown,
remove_non_printable_characters,
strip_html_tags,
)
class CleanMixin:
@@ -57,6 +61,7 @@ class CleanMixin:
# By default, newline characters are removed
remove_newline = True
is_markdown = False
try:
if hasattr(self, 'serializer_class'):
@@ -64,11 +69,12 @@ class CleanMixin:
field = model._meta.get_field(field)
# The following field types allow newline characters
allow_newline = [InvenTreeNotesField]
allow_newline = [(InvenTreeNotesField, True)]
for field_type in allow_newline:
if issubclass(type(field), field_type):
if issubclass(type(field), field_type[0]):
remove_newline = False
is_markdown = field_type[1]
break
except AttributeError:
@@ -80,6 +86,9 @@ class CleanMixin:
cleaned, remove_newline=remove_newline
)
if is_markdown:
cleaned = clean_markdown(cleaned)
return cleaned
def clean_data(self, data: dict) -> dict:

View File

@@ -882,8 +882,8 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
try:
self.remote_image_file = download_image_from_url(url)
except Exception as exc:
except Exception:
self.remote_image_file = None
raise ValidationError(str(exc))
raise ValidationError(_('Failed to download image from remote URL'))
return url

View File

@@ -33,7 +33,8 @@ from . import config, locales
checkMinPythonVersion()
INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
INVENTREE_BASE_URL = 'https://inventree.org'
INVENTREE_NEWS_URL = f'{INVENTREE_BASE_URL}/news/feed.atom'
# Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv or 'TESTING' in os.environ
@@ -170,10 +171,11 @@ DBBACKUP_STORAGE = get_setting(
# Default backup configuration
DBBACKUP_STORAGE_OPTIONS = get_setting(
'INVENTREE_BACKUP_OPTIONS', 'backup_options', None
'INVENTREE_BACKUP_OPTIONS',
'backup_options',
default_value={'location': config.get_backup_dir()},
typecast=dict,
)
if DBBACKUP_STORAGE_OPTIONS is None:
DBBACKUP_STORAGE_OPTIONS = {'location': config.get_backup_dir()}
INVENTREE_ADMIN_ENABLED = get_boolean_setting(
'INVENTREE_ADMIN_ENABLED', config_key='admin_enabled', default_value=True
@@ -1059,26 +1061,40 @@ if (
sys.exit(-1)
COOKIE_MODE = (
str(get_setting('INVENTREE_COOKIE_SAMESITE', 'cookie.samesite', 'None'))
str(get_setting('INVENTREE_COOKIE_SAMESITE', 'cookie.samesite', 'False'))
.lower()
.strip()
)
valid_cookie_modes = {'lax': 'Lax', 'strict': 'Strict', 'none': None, 'null': None}
# Valid modes (as per the django settings documentation)
valid_cookie_modes = ['lax', 'strict', 'none']
if COOKIE_MODE not in valid_cookie_modes.keys():
logger.error('Invalid cookie samesite mode: %s', COOKIE_MODE)
sys.exit(-1)
COOKIE_MODE = valid_cookie_modes[COOKIE_MODE.lower()]
if not DEBUG and not TESTING and COOKIE_MODE in valid_cookie_modes:
# Set the cookie mode (in production mode only)
COOKIE_MODE = COOKIE_MODE.capitalize()
else:
# Default to False, as per the Django settings
COOKIE_MODE = False
# Additional CSRF settings
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_SAMESITE = COOKIE_MODE
SESSION_COOKIE_SAMESITE = COOKIE_MODE
SESSION_COOKIE_SECURE = get_boolean_setting(
'INVENTREE_SESSION_COOKIE_SECURE', 'cookie.secure', False
"""Set the SESSION_COOKIE_SECURE value based on the following rules:
- False if the server is running in DEBUG mode
- True if samesite cookie setting is set to 'None'
- Otherwise, use the value specified in the configuration file (or env var)
"""
SESSION_COOKIE_SECURE = (
False
if DEBUG
else (
SESSION_COOKIE_SAMESITE == 'None'
or get_boolean_setting('INVENTREE_SESSION_COOKIE_SECURE', 'cookie.secure', True)
)
)
USE_X_FORWARDED_HOST = get_boolean_setting(
@@ -1230,23 +1246,29 @@ MARKDOWNIFY = {
'abbr',
'b',
'blockquote',
'code',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'hr',
'i',
'img',
'li',
'ol',
'p',
'pre',
's',
'strong',
'ul',
'table',
'thead',
'tbody',
'th',
'tr',
'td',
'ul',
],
}
}
@@ -1284,6 +1306,9 @@ PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# Flag to allow table events during testing
TESTING_TABLE_EVENTS = False
# Flag to allow pricing recalculations during testing
TESTING_PRICING = False
# User interface customization values
CUSTOM_LOGO = get_custom_file(
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True

View File

@@ -5,7 +5,6 @@ import logging
from datetime import timedelta
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_q.models import Success
from django_q.status import Stat
@@ -63,13 +62,13 @@ def check_system_health(**kwargs):
if not is_worker_running(**kwargs): # pragma: no cover
result = False
logger.warning(_('Background worker check failed'))
logger.warning('Background worker check failed')
if not InvenTree.email.is_email_configured(): # pragma: no cover
result = False
logger.warning(_('Email backend not configured'))
logger.warning('Email backend not configured')
if not result: # pragma: no cover
logger.warning(_('InvenTree system health checks failed'))
logger.warning('InvenTree system health checks failed')
return result

View File

@@ -264,7 +264,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
MAX_QUERY_TIME = 7.5
@contextmanager
def assertNumQueriesLessThan(self, value, using='default', verbose=None, url=None):
def assertNumQueriesLessThan(self, value, using='default', verbose=False, url=None):
"""Context manager to check that the number of queries is less than a certain value.
Example:
@@ -282,10 +282,8 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
) # pragma: no cover
if verbose or n >= value:
msg = '\r\n%s' % json.dumps(
context.captured_queries, indent=4
) # pragma: no cover
if verbose and n >= value:
msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover
else:
msg = None

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 = '0.16.0'
INVENTREE_SW_VERSION = '0.16.8'
logger = logging.getLogger('inventree')

View File

@@ -1502,12 +1502,19 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
'quantity': _(f'Allocated quantity ({q}) must not exceed available stock quantity ({a})')
})
# Allocated quantity cannot cause the stock item to be over-allocated
# Ensure that we do not 'over allocate' a stock item
available = decimal.Decimal(self.stock_item.quantity)
allocated = decimal.Decimal(self.stock_item.allocation_count())
quantity = decimal.Decimal(self.quantity)
build_allocation_count = decimal.Decimal(self.stock_item.build_allocation_count(
exclude_allocations={'pk': self.pk}
))
sales_allocation_count = decimal.Decimal(self.stock_item.sales_order_allocation_count())
if available - allocated + quantity < quantity:
total_allocation = (
build_allocation_count + sales_allocation_count + quantity
)
if total_allocation > available:
raise ValidationError({
'quantity': _('Stock item is over-allocated')
})

View File

@@ -3,6 +3,7 @@
{% load static %}
{% load i18n %}
{% load generic %}
{% load barcode %}
{% load inventree_extras %}
{% block page_title %}
@@ -277,7 +278,7 @@ src="{% static 'img/blank_image.png' %}"
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Build Order QR Code" escape %}',
'{{ build.barcode }}'
`{% clean_barcode build.barcode %}`
);
});

View File

@@ -920,6 +920,65 @@ class BuildAllocationTest(BuildAPITest):
expected_code=201,
)
class BuildItemTest(BuildAPITest):
"""Unit tests for build items.
For this test, we will be using Build ID=1;
- This points to Part 100 (see fixture data in part.yaml)
- This Part already has a BOM with 4 items (see fixture data in bom.yaml)
- There are no BomItem objects yet created for this build
"""
def setUp(self):
"""Basic operation as part of test suite setup"""
super().setUp()
self.assignRole('build.add')
self.assignRole('build.change')
self.build = Build.objects.get(pk=1)
# Regenerate BuildLine objects
self.build.create_build_line_items()
# Record number of build items which exist at the start of each test
self.n = BuildItem.objects.count()
def test_update_overallocated(self):
"""Test update of overallocated stock items."""
si = StockItem.objects.get(pk=2)
# Find line item
line = self.build.build_lines.all().filter(bom_item__sub_part=si.part).first()
# Set initial stock item quantity
si.quantity = 100
si.save()
# Create build item
bi = BuildItem(
build_line=line,
stock_item=si,
quantity=100
)
bi.save()
# Reduce stock item quantity
si.quantity = 50
si.save()
# Reduce build item quantity
url = reverse('api-build-item-detail', kwargs={'pk': bi.pk})
self.patch(
url,
{
"quantity": 50,
},
expected_code=200,
)
class BuildOverallocationTest(BuildAPITest):
"""Unit tests for over allocation of stock items against a build order.
@@ -966,6 +1025,12 @@ class BuildOverallocationTest(BuildAPITest):
outputs = cls.build.build_outputs.all()
cls.build.complete_build_output(outputs[0], cls.user)
def setUp(self):
"""Basic operation as part of test suite setup"""
super().setUp()
self.generate_exchange_rates()
def test_setup(self):
"""Validate expected state after set-up."""
self.assertEqual(self.build.incomplete_outputs.count(), 0)
@@ -994,7 +1059,7 @@ class BuildOverallocationTest(BuildAPITest):
'accept_overallocated': 'accept',
},
expected_code=201,
max_query_count=550, # TODO: Come back and refactor this
max_query_count=1000, # TODO: Come back and refactor this
)
self.build.refresh_from_db()
@@ -1015,9 +1080,11 @@ class BuildOverallocationTest(BuildAPITest):
'accept_overallocated': 'trim',
},
expected_code=201,
max_query_count=555, # TODO: Come back and refactor this
max_query_count=1000, # TODO: Come back and refactor this
)
# Note: Large number of queries is due to pricing recalculation for each stock item
self.build.refresh_from_db()
# Build should have been marked as complete

View File

@@ -29,5 +29,5 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunPython(set_default_currency),
migrations.RunPython(set_default_currency, reverse_code=migrations.RunPython.noop),
]

View File

@@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _
import common.models
import InvenTree.helpers
from InvenTree.ready import isImportingData
from InvenTree.ready import isImportingData, isRebuildingData
from plugin import registry
from plugin.models import NotificationUserSetting, PluginConfig
from users.models import Owner
@@ -185,9 +185,20 @@ class MethodStorageClass:
Is initialized on startup as one instance named `storage` in this file.
"""
liste = None
methods_list = None
user_settings = {}
@property
def methods(self):
"""Return all available methods.
This is cached, and stored internally.
"""
if self.methods_list is None:
self.collect()
return self.methods_list
def collect(self, selected_classes=None):
"""Collect all classes in the environment that are notification methods.
@@ -196,7 +207,8 @@ class MethodStorageClass:
Args:
selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
"""
logger.debug('Collecting notification methods')
logger.debug('Collecting notification methods...')
current_method = (
InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
)
@@ -219,8 +231,12 @@ class MethodStorageClass:
item.plugin = plugin() if plugin else None
filtered_list[ref] = item
storage.liste = list(filtered_list.values())
logger.info('Found %s notification methods', len(storage.liste))
storage.methods_list = list(filtered_list.values())
logger.info('Found %s notification methods', len(storage.methods_list))
for item in storage.methods_list:
logger.debug(' - %s', str(item))
def get_usersettings(self, user) -> list:
"""Returns all user settings for a specific user.
@@ -234,7 +250,8 @@ class MethodStorageClass:
list: All applicablae notification settings.
"""
methods = []
for item in storage.liste:
for item in storage.methods:
if item.USER_SETTING:
new_key = f'NOTIFICATION_METHOD_{item.METHOD_NAME.upper()}'
@@ -250,6 +267,7 @@ class MethodStorageClass:
'icon': getattr(item, 'METHOD_ICON', ''),
'method': item.METHOD_NAME,
})
return methods
@@ -352,7 +370,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
delivery_methods = kwargs.get('delivery_methods', None)
# Check if data is importing currently
if isImportingData():
if isImportingData() or isRebuildingData():
return
# Resolve object reference
@@ -422,7 +440,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
# Collect possible methods
if delivery_methods is None:
delivery_methods = storage.liste or []
delivery_methods = storage.methods or []
else:
delivery_methods = delivery_methods - IGNORED_NOTIFICATION_CLS
@@ -439,7 +457,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
# Set delivery flag
common.models.NotificationEntry.notify(category, obj_ref_value)
else:
logger.debug("No possible users for notification '%s'", category)
logger.info("No possible users for notification '%s'", category)
def trigger_superuser_notification(plugin: PluginConfig, msg: str):

View File

@@ -546,6 +546,10 @@ class AttachmentSerializer(InvenTreeModelSerializer):
model_type = self.validated_data.get('model_type', None)
# If the model type is not specified, attempt to infer it from the instance
if model_type is None and self.instance:
model_type = self.instance.model_type
# Ensure that the user has permission to attach files to the specified model
user = self.context.get('request').user

View File

@@ -70,6 +70,10 @@ def update_news_feed():
if entry.id in id_list:
continue
# Enforce proper links for the entries
if entry.link and str(entry.link).startswith('/'):
entry.link = settings.INVENTREE_BASE_URL + str(entry.link)
# Create entry
try:
NewsFeedEntry.objects.create(

View File

@@ -1052,7 +1052,10 @@ class SupplierPriceBreak(common.models.PriceBreak):
)
def after_save_supplier_price(sender, instance, created, **kwargs):
"""Callback function when a SupplierPriceBreak is created or updated."""
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if (
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
and not InvenTree.ready.isImportingData()
):
if instance.part and instance.part.part:
instance.part.part.schedule_pricing_update(create=True)
@@ -1064,6 +1067,9 @@ def after_save_supplier_price(sender, instance, created, **kwargs):
)
def after_delete_supplier_price(sender, instance, **kwargs):
"""Callback function when a SupplierPriceBreak is deleted."""
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if (
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
and not InvenTree.ready.isImportingData()
):
if instance.part and instance.part.part:
instance.part.part.schedule_pricing_update(create=False)

View File

@@ -1,6 +1,7 @@
{% extends "page_base.html" %}
{% load static %}
{% load i18n %}
{% load barcode %}
{% load inventree_extras %}
{% block page_title %}
@@ -303,7 +304,7 @@ onPanelLoad('supplier-part-notes', function() {
$("#show-qr-code").click(function() {
showQRDialog(
'{% trans "Supplier Part QR Code" escape %}',
'{{ part.barcode }}'
`{% clean_barcode part.barcode %}`
);
});

View File

@@ -156,6 +156,52 @@ class CompanyTest(InvenTreeAPITestCase):
len(self.get(url, data={'active': False}, expected_code=200).data), 1
)
def test_company_notes(self):
"""Test the markdown 'notes' field for the Company model."""
pk = Company.objects.first().pk
url = reverse('api-company-detail', kwargs={'pk': pk})
# Attempt to inject malicious markdown into the "notes" field
xss = [
'[Click me](javascript:alert(123))',
'![x](javascript:alert(123))',
'![Uh oh...]("onerror="alert(\'XSS\'))',
]
for note in xss:
response = self.patch(url, {'notes': note}, expected_code=400)
self.assertIn(
'Data contains prohibited markdown content', str(response.data)
)
# Tests with disallowed tags
invalid_tags = [
'<iframe src="javascript:alert(123)"></iframe>',
'<canvas>A disallowed tag!</canvas>',
]
for note in invalid_tags:
response = self.patch(url, {'notes': note}, expected_code=400)
self.assertIn('Remove HTML tags from this value', str(response.data))
# The following markdown is safe, and should be accepted
good = [
'This is a **bold** statement',
'This is a *italic* statement',
'This is a [link](https://www.google.com)',
'This is an ![image](https://www.google.com/test.jpg)',
'This is a `code` block',
'This text has ~~strikethrough~~ formatting',
'This text has a raw link - https://www.google.com - and should still pass the test',
]
for note in good:
response = self.patch(url, {'notes': note}, expected_code=200)
self.assertEqual(response.data['notes'], note)
class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models."""

View File

@@ -117,7 +117,7 @@ use_x_forwarded_port: false
# Cookie settings
cookie:
secure: false
samesite: none
samesite: false
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/adamchainz/django-cors-headers)
cors:

View File

@@ -2196,7 +2196,7 @@ class ReturnOrder(TotalPriceMixin, Order):
# endregion
@transaction.atomic
def receive_line_item(self, line, location, user, note=''):
def receive_line_item(self, line, location, user, note='', **kwargs):
"""Receive a line item against this ReturnOrder.
Rules:
@@ -2222,7 +2222,7 @@ class ReturnOrder(TotalPriceMixin, Order):
deltas['customer'] = stock_item.customer.pk
# Update the StockItem
stock_item.status = StockStatus.QUARANTINED.value
stock_item.status = kwargs.get('status', StockStatus.QUARANTINED.value)
stock_item.location = location
stock_item.customer = None
stock_item.sales_order = None

View File

@@ -2,6 +2,7 @@
{% load i18n %}
{% load static %}
{% load barcode %}
{% load inventree_extras %}
{% load generic %}
@@ -312,7 +313,7 @@ $("#export-order").click(function() {
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Purchase Order QR Code" escape %}',
'{{ order.barcode }}'
`{% clean_barcode order.barcode %}`
);
});

View File

@@ -2,6 +2,7 @@
{% load i18n %}
{% load static %}
{% load barcode %}
{% load inventree_extras %}
{% load generic %}
@@ -257,7 +258,7 @@ $('#print-order-report').click(function() {
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Return Order QR Code" escape %}',
'{{ order.barcode }}'
`{% clean_barcode order.barcode %}`
);
});

View File

@@ -2,6 +2,7 @@
{% load i18n %}
{% load static %}
{% load barcode %}
{% load inventree_extras %}
{% load generic %}
@@ -319,7 +320,7 @@ $('#print-order-report').click(function() {
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Sales Order QR Code" escape %}',
'{{ order.barcode }}'
`{% clean_barcode order.barcode %}`
);
});

View File

@@ -353,7 +353,8 @@ class LineItemPricing(PartPricing):
try:
part_id = self.request.POST.get('pk')
part = Part.objects.get(id=part_id)
except Part.DoesNotExist:
except Exception:
# Part not found, or invalid ID
return None
else:
return None

View File

@@ -1931,7 +1931,7 @@ class Part(
return pricing
def schedule_pricing_update(self, create: bool = False, test: bool = False):
def schedule_pricing_update(self, create: bool = False):
"""Helper function to schedule a pricing update.
Importantly, catches any errors which may occur during deletion of related objects,
@@ -1941,7 +1941,6 @@ class Part(
Arguments:
create: Whether or not a new PartPricing object should be created if it does not already exist
test: Whether or not the pricing update is allowed during unit tests
"""
try:
self.refresh_from_db()
@@ -1952,7 +1951,7 @@ class Part(
pricing = self.pricing
if create or pricing.pk:
pricing.schedule_for_update(test=test)
pricing.schedule_for_update()
except IntegrityError:
# If this part instance has been deleted,
# some post-delete or post-save signals may still be fired
@@ -2532,7 +2531,8 @@ class PartPricing(common.models.MetaMixin):
- Detailed pricing information is very context specific in any case
"""
price_modified = False
# When calculating assembly pricing, we limit the depth of the calculation
MAX_PRICING_DEPTH = 50
@property
def is_valid(self):
@@ -2561,14 +2561,10 @@ class PartPricing(common.models.MetaMixin):
return result
def schedule_for_update(self, counter: int = 0, test: bool = False):
def schedule_for_update(self, counter: int = 0):
"""Schedule this pricing to be updated."""
import InvenTree.ready
# If we are running within CI, only schedule the update if the test flag is set
if settings.TESTING and not test:
return
# If importing data, skip pricing update
if InvenTree.ready.isImportingData():
return
@@ -2612,7 +2608,7 @@ class PartPricing(common.models.MetaMixin):
logger.debug('Pricing for %s already scheduled for update - skipping', p)
return
if counter > 25:
if counter > self.MAX_PRICING_DEPTH:
# Prevent infinite recursion / stack depth issues
logger.debug(
counter, f'Skipping pricing update for {p} - maximum depth exceeded'
@@ -2631,16 +2627,36 @@ class PartPricing(common.models.MetaMixin):
import part.tasks as part_tasks
# Pricing calculations are performed in the background,
# unless the TESTING_PRICING flag is set
background = not settings.TESTING or not settings.TESTING_PRICING
# Offload task to update the pricing
# Force async, to prevent running in the foreground
# Force async, to prevent running in the foreground (unless in testing mode)
InvenTree.tasks.offload_task(
part_tasks.update_part_pricing, self, counter=counter, force_async=True
part_tasks.update_part_pricing,
self,
counter=counter,
force_async=background,
)
def update_pricing(self, counter: int = 0, cascade: bool = True):
"""Recalculate all cost data for the referenced Part instance."""
# If importing data, skip pricing update
def update_pricing(
self,
counter: int = 0,
cascade: bool = True,
previous_min=None,
previous_max=None,
):
"""Recalculate all cost data for the referenced Part instance.
Arguments:
counter: Recursion counter (used to prevent infinite recursion)
cascade: If True, update pricing for all assemblies and templates which use this part
previous_min: Previous minimum price (used to prevent further updates if unchanged)
previous_max: Previous maximum price (used to prevent further updates if unchanged)
"""
# If importing data, skip pricing update
if InvenTree.ready.isImportingData():
return
@@ -2671,18 +2687,25 @@ class PartPricing(common.models.MetaMixin):
# Background worker processes may try to concurrently update
pass
pricing_changed = False
# Without previous pricing data, we assume that the pricing has changed
if previous_min != self.overall_min or previous_max != self.overall_max:
pricing_changed = True
# Update parent assemblies and templates
if cascade and self.price_modified:
if pricing_changed and cascade:
self.update_assemblies(counter)
self.update_templates(counter)
def update_assemblies(self, counter: int = 0):
"""Schedule updates for any assemblies which use this part."""
# If the linked Part is used in any assemblies, schedule a pricing update for those assemblies
used_in_parts = self.part.get_used_in()
for p in used_in_parts:
p.pricing.schedule_for_update(counter + 1)
p.pricing.schedule_for_update(counter=counter + 1)
def update_templates(self, counter: int = 0):
"""Schedule updates for any template parts above this part."""
@@ -2698,13 +2721,13 @@ class PartPricing(common.models.MetaMixin):
try:
self.update_overall_cost()
except IntegrityError:
except Exception:
# If something has happened to the Part model, might throw an error
pass
try:
super().save(*args, **kwargs)
except IntegrityError:
except Exception:
# This error may be thrown if there is already duplicate pricing data
pass
@@ -2772,9 +2795,6 @@ class PartPricing(common.models.MetaMixin):
any_max_elements = True
old_bom_cost_min = self.bom_cost_min
old_bom_cost_max = self.bom_cost_max
if any_min_elements:
self.bom_cost_min = cumulative_min
else:
@@ -2785,12 +2805,6 @@ class PartPricing(common.models.MetaMixin):
else:
self.bom_cost_max = None
if (
old_bom_cost_min != self.bom_cost_min
or old_bom_cost_max != self.bom_cost_max
):
self.price_modified = True
if save:
self.save()
@@ -2854,12 +2868,6 @@ class PartPricing(common.models.MetaMixin):
if purchase_max is None or cost > purchase_max:
purchase_max = cost
if (
self.purchase_cost_min != purchase_min
or self.purchase_cost_max != purchase_max
):
self.price_modified = True
self.purchase_cost_min = purchase_min
self.purchase_cost_max = purchase_max
@@ -2886,12 +2894,6 @@ class PartPricing(common.models.MetaMixin):
if max_int_cost is None or cost > max_int_cost:
max_int_cost = cost
if (
self.internal_cost_min != min_int_cost
or self.internal_cost_max != max_int_cost
):
self.price_modified = True
self.internal_cost_min = min_int_cost
self.internal_cost_max = max_int_cost
@@ -2927,12 +2929,6 @@ class PartPricing(common.models.MetaMixin):
if max_sup_cost is None or cost > max_sup_cost:
max_sup_cost = cost
if (
self.supplier_price_min != min_sup_cost
or self.supplier_price_max != max_sup_cost
):
self.price_modified = True
self.supplier_price_min = min_sup_cost
self.supplier_price_max = max_sup_cost
@@ -2968,9 +2964,6 @@ class PartPricing(common.models.MetaMixin):
if variant_max is None or v_max > variant_max:
variant_max = v_max
if self.variant_cost_min != variant_min or self.variant_cost_max != variant_max:
self.price_modified = True
self.variant_cost_min = variant_min
self.variant_cost_max = variant_max
@@ -3091,12 +3084,6 @@ class PartPricing(common.models.MetaMixin):
if max_sell_history is None or cost > max_sell_history:
max_sell_history = cost
if (
self.sale_history_min != min_sell_history
or self.sale_history_max != max_sell_history
):
self.price_modified = True
self.sale_history_min = min_sell_history
self.sale_history_max = max_sell_history
@@ -4509,7 +4496,10 @@ def update_bom_build_lines(sender, instance, created, **kwargs):
def update_pricing_after_edit(sender, instance, created, **kwargs):
"""Callback function when a part price break is created or updated."""
# Update part pricing *unless* we are importing data
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if (
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
and not InvenTree.ready.isImportingData()
):
if instance.part:
instance.part.schedule_pricing_update(create=True)
@@ -4526,7 +4516,10 @@ def update_pricing_after_edit(sender, instance, created, **kwargs):
def update_pricing_after_delete(sender, instance, **kwargs):
"""Callback function when a part price break is deleted."""
# Update part pricing *unless* we are importing data
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if (
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
and not InvenTree.ready.isImportingData()
):
if instance.part:
instance.part.schedule_pricing_update(create=False)

View File

@@ -73,7 +73,11 @@ def update_part_pricing(pricing: part.models.PartPricing, counter: int = 0):
"""
logger.info('Updating part pricing for %s', pricing.part)
pricing.update_pricing(counter=counter)
pricing.update_pricing(
counter=counter,
previous_min=pricing.overall_min,
previous_max=pricing.overall_max,
)
@scheduled_task(ScheduledTask.DAILY)

View File

@@ -2,6 +2,7 @@
{% load static %}
{% load i18n %}
{% load barcode %}
{% load inventree_extras %}
{% block sidebar %}
@@ -451,7 +452,7 @@
$("#show-qr-code").click(function() {
showQRDialog(
'{% trans "Part QR Code" escape %}',
'{{ part.barcode }}',
`{% clean_barcode part.barcode %}`
);
});

View File

@@ -1,6 +1,7 @@
"""Unit tests for Part pricing calculations."""
from django.core.exceptions import ObjectDoesNotExist
from django.test.utils import override_settings
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
@@ -88,6 +89,7 @@ class PartPricingTests(InvenTreeTestCase):
part=self.sp_2, quantity=10, price=4.55, price_currency='GBP'
)
@override_settings(TESTING_PRICING=True)
def test_pricing_data(self):
"""Test link between Part and PartPricing model."""
# Initially there is no associated Pricing data
@@ -113,6 +115,7 @@ class PartPricingTests(InvenTreeTestCase):
"""Ensure that conversion behaves properly with missing rates."""
...
@override_settings(TESTING_PRICING=True)
def test_simple(self):
"""Tests for hard-coded values."""
pricing = self.part.pricing
@@ -144,6 +147,7 @@ class PartPricingTests(InvenTreeTestCase):
self.assertEqual(pricing.overall_min, Money('0.111111', 'USD'))
self.assertEqual(pricing.overall_max, Money('25', 'USD'))
@override_settings(TESTING_PRICING=True)
def test_supplier_part_pricing(self):
"""Test for supplier part pricing."""
pricing = self.part.pricing
@@ -157,19 +161,22 @@ class PartPricingTests(InvenTreeTestCase):
# Creating price breaks will cause the pricing to be updated
self.create_price_breaks()
pricing.update_pricing()
pricing = self.part.pricing
pricing.refresh_from_db()
self.assertAlmostEqual(float(pricing.overall_min.amount), 2.015, places=2)
self.assertAlmostEqual(float(pricing.overall_max.amount), 3.06, places=2)
# Delete all supplier parts and re-calculate
self.part.supplier_parts.all().delete()
pricing.update_pricing()
pricing = self.part.pricing
pricing.refresh_from_db()
self.assertIsNone(pricing.supplier_price_min)
self.assertIsNone(pricing.supplier_price_max)
@override_settings(TESTING_PRICING=True)
def test_internal_pricing(self):
"""Tests for internal price breaks."""
# Ensure internal pricing is enabled
@@ -189,7 +196,8 @@ class PartPricingTests(InvenTreeTestCase):
part=self.part, quantity=ii + 1, price=10 - ii, price_currency=currency
)
pricing.update_internal_cost()
pricing = self.part.pricing
pricing.refresh_from_db()
# Expected money value
m_expected = Money(10 - ii, currency)
@@ -202,6 +210,7 @@ class PartPricingTests(InvenTreeTestCase):
self.assertEqual(pricing.internal_cost_max, Money(10, currency))
self.assertEqual(pricing.overall_max, Money(10, currency))
@override_settings(TESTING_PRICING=True)
def test_stock_item_pricing(self):
"""Test for stock item pricing data."""
# Create a part
@@ -244,6 +253,7 @@ class PartPricingTests(InvenTreeTestCase):
self.assertEqual(pricing.overall_min, Money(1.176471, 'USD'))
self.assertEqual(pricing.overall_max, Money(6.666667, 'USD'))
@override_settings(TESTING_PRICING=True)
def test_bom_pricing(self):
"""Unit test for BOM pricing calculations."""
pricing = self.part.pricing
@@ -253,7 +263,8 @@ class PartPricingTests(InvenTreeTestCase):
currency = 'AUD'
for ii in range(10):
# Create pricing out of order, to ensure min/max values are calculated correctly
for ii in range(5):
# Create a new part for the BOM
sub_part = part.models.Part.objects.create(
name=f'Sub Part {ii}',
@@ -274,15 +285,21 @@ class PartPricingTests(InvenTreeTestCase):
part=self.part, sub_part=sub_part, quantity=5
)
pricing.update_bom_cost()
# Check that the values have been updated correctly
self.assertEqual(pricing.currency, 'USD')
# Final overall pricing checks
self.assertEqual(pricing.overall_min, Money('366.666665', 'USD'))
self.assertEqual(pricing.overall_max, Money('550', 'USD'))
# Price range should have been automatically updated
self.part.refresh_from_db()
pricing = self.part.pricing
expected_min = 100
expected_max = 150
# Final overall pricing checks
self.assertEqual(pricing.overall_min, Money(expected_min, 'USD'))
self.assertEqual(pricing.overall_max, Money(expected_max, 'USD'))
@override_settings(TESTING_PRICING=True)
def test_purchase_pricing(self):
"""Unit tests for historical purchase pricing."""
self.create_price_breaks()
@@ -350,6 +367,7 @@ class PartPricingTests(InvenTreeTestCase):
# Max cost in USD
self.assertAlmostEqual(float(pricing.purchase_cost_max.amount), 6.95, places=2)
@override_settings(TESTING_PRICING=True)
def test_delete_with_pricing(self):
"""Test for deleting a part which has pricing information."""
# Create some pricing data
@@ -374,6 +392,7 @@ class PartPricingTests(InvenTreeTestCase):
with self.assertRaises(part.models.PartPricing.DoesNotExist):
pricing.refresh_from_db()
@override_settings(TESTING_PRICING=True)
def test_delete_without_pricing(self):
"""Test that we can delete a part which does not have pricing information."""
pricing = self.part.pricing
@@ -389,6 +408,7 @@ class PartPricingTests(InvenTreeTestCase):
with self.assertRaises(part.models.Part.DoesNotExist):
self.part.refresh_from_db()
@override_settings(TESTING_PRICING=True)
def test_check_missing_pricing(self):
"""Tests for check_missing_pricing background task.
@@ -412,6 +432,7 @@ class PartPricingTests(InvenTreeTestCase):
# Check that PartPricing objects have been created
self.assertEqual(part.models.PartPricing.objects.count(), 101)
@override_settings(TESTING_PRICING=True)
def test_delete_part_with_stock_items(self):
"""Test deleting a part instance with stock items.
@@ -432,7 +453,7 @@ class PartPricingTests(InvenTreeTestCase):
)
# Manually schedule a pricing update (does not happen automatically in testing)
p.schedule_pricing_update(create=True, test=True)
p.schedule_pricing_update(create=True)
# Check that a PartPricing object exists
self.assertTrue(part.models.PartPricing.objects.filter(part=p).exists())
@@ -444,5 +465,84 @@ class PartPricingTests(InvenTreeTestCase):
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
# Try to update pricing (should fail gracefully as the Part has been deleted)
p.schedule_pricing_update(create=False, test=True)
p.schedule_pricing_update(create=False)
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
@override_settings(TESTING_PRICING=True)
def test_multi_level_bom(self):
"""Test that pricing for multi-level BOMs is calculated correctly."""
# Create some parts
A1 = part.models.Part.objects.create(
name='A1', description='A1', assembly=True, component=True
)
B1 = part.models.Part.objects.create(
name='B1', description='B1', assembly=True, component=True
)
C1 = part.models.Part.objects.create(
name='C1', description='C1', assembly=True, component=True
)
D1 = part.models.Part.objects.create(
name='D1', description='D1', assembly=True, component=True
)
D2 = part.models.Part.objects.create(
name='D2', description='D2', assembly=True, component=True
)
D3 = part.models.Part.objects.create(
name='D3', description='D3', assembly=True, component=True
)
# BOM Items
part.models.BomItem.objects.create(part=A1, sub_part=B1, quantity=10)
part.models.BomItem.objects.create(part=B1, sub_part=C1, quantity=2)
part.models.BomItem.objects.create(part=C1, sub_part=D1, quantity=3)
part.models.BomItem.objects.create(part=C1, sub_part=D2, quantity=4)
part.models.BomItem.objects.create(part=C1, sub_part=D3, quantity=5)
# Pricing data (only for low-level D parts)
P1 = D1.pricing
P1.override_min = 4.50
P1.override_max = 5.50
P1.save()
P1.update_pricing()
P2 = D2.pricing
P2.override_min = 6.50
P2.override_max = 7.50
P2.save()
P2.update_pricing()
P3 = D3.pricing
P3.override_min = 8.50
P3.override_max = 9.50
P3.save()
P3.update_pricing()
# Simple checks for low-level BOM items
self.assertEqual(D1.pricing.overall_min, Money(4.50, 'USD'))
self.assertEqual(D1.pricing.overall_max, Money(5.50, 'USD'))
self.assertEqual(D2.pricing.overall_min, Money(6.50, 'USD'))
self.assertEqual(D2.pricing.overall_max, Money(7.50, 'USD'))
self.assertEqual(D3.pricing.overall_min, Money(8.50, 'USD'))
self.assertEqual(D3.pricing.overall_max, Money(9.50, 'USD'))
# Calculate pricing for "C" level part
c_min = 3 * 4.50 + 4 * 6.50 + 5 * 8.50
c_max = 3 * 5.50 + 4 * 7.50 + 5 * 9.50
self.assertEqual(C1.pricing.overall_min, Money(c_min, 'USD'))
self.assertEqual(C1.pricing.overall_max, Money(c_max, 'USD'))
# Calculate pricing for "A" and "B" level parts
b_min = 2 * c_min
b_max = 2 * c_max
a_min = 10 * b_min
a_max = 10 * b_max
self.assertEqual(B1.pricing.overall_min, Money(b_min, 'USD'))
self.assertEqual(B1.pricing.overall_max, Money(b_max, 'USD'))
self.assertEqual(A1.pricing.overall_min, Money(a_min, 'USD'))
self.assertEqual(A1.pricing.overall_max, Money(a_max, 'USD'))

View File

@@ -95,7 +95,7 @@ def notification_list(context, *args, **kwargs):
'description': a.__doc__,
'name': a.__name__,
}
for a in storage.liste
for a in storage.methods
]

View File

@@ -125,12 +125,14 @@ class ReportConfig(AppConfig):
# Read the existing template file
data = template_file.open('r').read()
logger.info("Creating new label template: '%s'", template['name'])
# Create a new entry
report.models.LabelTemplate.objects.create(
**template, template=ContentFile(data, os.path.basename(filename))
)
try:
# Create a new entry
report.models.LabelTemplate.objects.create(
**template, template=ContentFile(data, os.path.basename(filename))
)
logger.info("Creating new label template: '%s'", template['name'])
except Exception:
pass
def create_default_reports(self):
"""Create default report templates."""
@@ -212,9 +214,11 @@ class ReportConfig(AppConfig):
# Read the existing template file
data = template_file.open('r').read()
logger.info("Creating new report template: '%s'", template['name'])
# Create a new entry
report.models.ReportTemplate.objects.create(
**template, template=ContentFile(data, os.path.basename(filename))
)
try:
report.models.ReportTemplate.objects.create(
**template, template=ContentFile(data, os.path.basename(filename))
)
logger.info("Created new report template: '%s'", template['name'])
except Exception:
pass

View File

@@ -2,9 +2,9 @@
import os
from django.db import connection, migrations
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import connection, migrations
import InvenTree.ready
@@ -48,7 +48,7 @@ def convert_legacy_labels(table_name, model_name, template_model):
except Exception:
# Table likely does not exist
if not InvenTree.ready.isInTestMode():
print(f"Legacy label table {table_name} not found - skipping migration")
print(f"\nLegacy label table {table_name} not found - skipping migration")
return 0
rows = cursor.fetchall()

View File

@@ -1,6 +1,7 @@
"""Template tags for rendering various barcodes."""
from django import template
from django.utils.safestring import mark_safe
import barcode as python_barcode
import qrcode.constants as ECL
@@ -26,6 +27,23 @@ def image_data(img, fmt='PNG'):
return report.helpers.encode_image_base64(img, fmt)
@register.simple_tag()
def clean_barcode(data):
"""Return a 'cleaned' string for encoding into a barcode / qrcode.
- This function runs the data through bleach, and removes any malicious HTML content.
- Used to render raw barcode data into the rendered HTML templates
"""
from InvenTree.helpers import strip_html_tags
cleaned_date = strip_html_tags(data)
# Remove back-tick character (prevent injection)
cleaned_date = cleaned_date.replace('`', '')
return mark_safe(cleaned_date)
@register.simple_tag()
def qrcode(data, **kwargs):
"""Return a byte-encoded QR code image.

View File

@@ -1161,10 +1161,12 @@ class StockItem(
location=location,
)
# Clear out allocation information for the stock item
self.customer = None
self.belongs_to = None
self.sales_order = None
self.location = location
self.clearAllocations()
trigger_event('stockitem.returnedfromcustomer', id=self.id)
@@ -1189,9 +1191,17 @@ class StockItem(
return False
def build_allocation_count(self):
"""Return the total quantity allocated to builds."""
query = self.allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
def build_allocation_count(self, **kwargs):
"""Return the total quantity allocated to builds, with optional filters."""
query = self.allocations.all()
if filter_allocations := kwargs.get('filter_allocations'):
query = query.filter(**filter_allocations)
if exclude_allocations := kwargs.get('exclude_allocations'):
query = query.exclude(**exclude_allocations)
query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
total = query['q']
@@ -1577,6 +1587,13 @@ class StockItem(
# Remove the equivalent number of items
self.take_stock(quantity, user, notes=notes)
# Rebuild the stock tree
try:
StockItem.objects.partial_rebuild(tree_id=self.tree_id)
except Exception:
logger.warning('Failed to rebuild stock tree during serializeStock')
StockItem.objects.rebuild()
@transaction.atomic
def copyHistoryFrom(self, other):
"""Copy stock history from another StockItem."""
@@ -1761,7 +1778,7 @@ class StockItem(
# Any "sales order allocations" for the other item must be assigned to this one
for allocation in other.sales_order_allocations.all():
allocation.stock_item = self()
allocation.stock_item = self
allocation.save()
# Prevent atomicity issues when we are merging our own "parent" part in
@@ -1811,7 +1828,7 @@ class StockItem(
for tree_id in tree_ids:
StockItem.objects.partial_rebuild(tree_id=tree_id)
except Exception:
logger.warning('Rebuilding entire StockItem tree')
logger.warning('Rebuilding entire StockItem tree during merge_stock_items')
StockItem.objects.rebuild()
@transaction.atomic
@@ -2284,14 +2301,16 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
"""Function to be executed after a StockItem object is deleted."""
from part import tasks as part_tasks
if not InvenTree.ready.isImportingData() and InvenTree.ready.canAppAccessDatabase(
allow_test=True
):
if InvenTree.ready.isImportingData():
return
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
# Run this check in the background
InvenTree.tasks.offload_task(
part_tasks.notify_low_stock_if_required, instance.part
)
if InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING):
# Schedule an update on parent part pricing
if instance.part:
instance.part.schedule_pricing_update(create=False)
@@ -2302,19 +2321,15 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
"""Hook function to be executed after StockItem object is saved/updated."""
from part import tasks as part_tasks
if (
created
and not InvenTree.ready.isImportingData()
and InvenTree.ready.canAppAccessDatabase(allow_test=True)
):
# Run this check in the background
InvenTree.tasks.offload_task(
part_tasks.notify_low_stock_if_required, instance.part
)
if created and not InvenTree.ready.isImportingData():
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
InvenTree.tasks.offload_task(
part_tasks.notify_low_stock_if_required, instance.part
)
# Schedule an update on parent part pricing
if instance.part:
instance.part.schedule_pricing_update(create=True)
if InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING):
if instance.part:
instance.part.schedule_pricing_update(create=True)
class StockItemTracking(InvenTree.models.InvenTreeModel):

View File

@@ -3,6 +3,7 @@
{% load plugin_extras %}
{% load inventree_extras %}
{% load generic %}
{% load barcode %}
{% load i18n %}
{% load l10n %}
@@ -534,7 +535,7 @@ $('#stock-edit-status').click(function () {
$("#show-qr-code").click(function() {
showQRDialog(
'{% trans "Stock Item QR Code" escape %}',
'{{ item.barcode }}',
`{% clean_barcode item.barcode %}`
);
});

View File

@@ -1,5 +1,6 @@
{% extends "stock/stock_app_base.html" %}
{% load static %}
{% load barcode %}
{% load inventree_extras %}
{% load plugin_extras %}
{% load i18n %}
@@ -391,7 +392,7 @@
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Stock Location QR Code" escape %}',
'{{ location.barcode }}'
`{% clean_barcode location.barcode %}`
);
});

View File

@@ -1713,7 +1713,7 @@ class StockTestResultTest(StockAPITestCase):
# Now, let's delete all the newly created items with a single API request
# However, we will provide incorrect filters
response = self.delete(
url, {'items': tests, 'filters': {'stock_item': 10}}, expected_code=204
url, {'items': tests, 'filters': {'stock_item': 10}}, expected_code=400
)
self.assertEqual(StockItemTestResult.objects.count(), n + 50)

View File

@@ -165,6 +165,7 @@ function supplierPartFields(options={}) {
icon: 'fa-box',
},
pack_quantity: {},
active: {},
};
if (options.part) {

View File

@@ -16,7 +16,7 @@ def clear_sessions(apps, schema_editor): # pragma: no cover
try:
engine = import_module(settings.SESSION_ENGINE)
engine.SessionStore.clear_expired()
print('Cleared all user sessions to deal with GHSA-2crp-q9pc-457j')
print('\nCleared all user sessions to deal with GHSA-2crp-q9pc-457j')
except Exception:
# Database may not be ready yet, so this does not matter anyhow
pass

View File

@@ -92,7 +92,7 @@ export function PrintingActions({
url: apiUrl(ApiEndpoints.label_print),
title: t`Print Label`,
fields: labelFields,
timeout: (items.length + 1) * 1000,
timeout: (items.length + 1) * 5000,
onClose: () => {
setPluginKey('');
},
@@ -121,7 +121,7 @@ export function PrintingActions({
const reportModal = useCreateApiFormModal({
title: t`Print Report`,
url: apiUrl(ApiEndpoints.report_print),
timeout: (items.length + 1) * 1000,
timeout: (items.length + 1) * 5000,
fields: {
template: {
filters: {

View File

@@ -39,6 +39,7 @@ import { UserRoles } from '../../enums/Roles';
import { companyFields } from '../../forms/CompanyForms';
import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AddressTable } from '../../tables/company/AddressTable';
import { ContactTable } from '../../tables/company/ContactTable';
@@ -143,7 +144,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
apiPath={apiUrl(ApiEndpoints.company_list, company.pk)}
src={company.image}
pk={company.pk}
refresh={refreshInstance}

View File

@@ -554,7 +554,7 @@ export default function PartDetail() {
name: 'builds',
label: t`Build Orders`,
icon: <IconTools />,
hidden: !part.assembly,
hidden: !part.assembly || !part.active,
content: part?.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton />
},
{

View File

@@ -65,8 +65,13 @@ function FilterAddGroup({
availableFilters: TableFilter[];
}) {
const filterOptions: TableFilterChoice[] = useMemo(() => {
let activeFilterNames =
tableState.activeFilters?.map((flt) => flt.name) ?? [];
// List of filter names which are already active on this table
let activeFilterNames: string[] = [];
if (tableState.activeFilters && tableState.activeFilters.length > 0) {
activeFilterNames =
tableState.activeFilters?.map((flt) => flt.name) ?? [];
}
return (
availableFilters
@@ -83,7 +88,7 @@ function FilterAddGroup({
const valueOptions: TableFilterChoice[] = useMemo(() => {
// Find the matching filter
let filter: TableFilter | undefined = availableFilters.find(
let filter: TableFilter | undefined = availableFilters?.find(
(flt) => flt.name === selectedFilter
);

View File

@@ -411,6 +411,11 @@ def backup(c, clean=False, path=None):
cmd = '--noinput --compress -v 2'
if path:
# Resolve the provided path
path = Path(path)
if not os.path.isabs(path):
path = localDir().joinpath(path).resolve()
cmd += f' -O {path}'
if clean:
@@ -442,6 +447,11 @@ def restore(
base_cmd = '--noinput --uncompress -v 2'
if path:
# Resolve the provided path
path = Path(path)
if not os.path.isabs(path):
path = localDir().joinpath(path).resolve()
base_cmd += f' -I {path}'
if ignore_database:
@@ -1418,11 +1428,11 @@ def docs_server(c, address='localhost:8080', compile_schema=False):
def clear_generated(c):
"""Clear generated files from `inv update`."""
# pyc/pyo files
run(c, 'find . -name "*.pyc" -exec rm -f {} +')
run(c, 'find . -name "*.pyo" -exec rm -f {} +')
run(c, 'find src -name "*.pyc" -exec rm -f {} +')
run(c, 'find src -name "*.pyo" -exec rm -f {} +')
# cache folders
run(c, 'find . -name "__pycache__" -exec rm -rf {} +')
run(c, 'find src -name "__pycache__" -exec rm -rf {} +')
# Generated translations
run(c, 'find . -name "django.mo" -exec rm -f {} +')
run(c, 'find . -name "messages.mo" -exec rm -f {} +')
run(c, 'find src -name "django.mo" -exec rm -f {} +')
run(c, 'find src -name "messages.mo" -exec rm -f {} +')