2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-05 06:32:55 +00:00

Merge branch 'master' into app-stock-coverage

This commit is contained in:
Matthias Mair
2024-09-15 21:10:05 +02:00
committed by GitHub
190 changed files with 52867 additions and 52013 deletions
+2 -2
View File
@@ -11,10 +11,10 @@ python3 -m venv /home/inventree/dev/venv --system-site-packages --upgrade-deps
invoke update -s
# Configure dev environment
invoke setup-dev
invoke dev.setup-dev
# Install required frontend packages
invoke frontend-install
invoke dev.frontend-install
# remove existing gitconfig created by "Avoiding Dubious Ownership" step
# so that it gets copied from host to the container to have your global
+1 -1
View File
@@ -9,7 +9,7 @@ runs:
shell: bash
run: |
invoke migrate
invoke import-fixtures
invoke dev.import-fixtures
invoke export-records -f data.json
python3 ./src/backend/InvenTree/manage.py flush --noinput
invoke migrate
+1 -1
View File
@@ -37,6 +37,6 @@ jobs:
install: true
apt-dependency: gettext
- name: Test Translations
run: invoke translate
run: invoke dev.translate
- name: Check Migration Files
run: python3 .github/scripts/check_migration_files.py
+4 -4
View File
@@ -68,7 +68,7 @@ jobs:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Set Up Python ${{ env.python_version }}
uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # pin@v5.1.1
uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # pin@v5.2.0
with:
python-version: ${{ env.python_version }}
- name: Version Check
@@ -97,7 +97,7 @@ jobs:
run: |
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke install
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke update
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke setup-dev
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke dev.setup-dev
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml up -d
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke wait
- name: Check Data Directory
@@ -115,10 +115,10 @@ jobs:
- name: Run Unit Tests
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --disable-pty
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke dev.test --disable-pty
- name: Run Migration Tests
run: |
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --migrations
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke dev.test --migrations
- name: Clean up test folder
run: |
rm -rf InvenTree/_testfolder
+25 -18
View File
@@ -94,7 +94,7 @@ jobs:
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # pin@v5.1.1
uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # pin@v5.2.0
with:
python-version: ${{ env.python_version }}
cache: "pip"
@@ -115,7 +115,7 @@ jobs:
- name: Checkout Code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # pin@v5.1.1
uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # pin@v5.2.0
with:
python-version: ${{ env.python_version }}
- name: Check Config
@@ -157,9 +157,9 @@ jobs:
dev-install: true
update: true
- name: Export API Documentation
run: invoke schema --ignore-warnings --filename src/backend/InvenTree/schema.yml
run: invoke dev.schema --ignore-warnings --filename src/backend/InvenTree/schema.yml
- name: Upload schema
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # pin@v4.3.6
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # pin@v4.4.0
with:
name: schema.yml
path: src/backend/InvenTree/schema.yml
@@ -191,7 +191,7 @@ jobs:
diff --color -u src/backend/InvenTree/schema.yml api.yaml
diff -u src/backend/InvenTree/schema.yml api.yaml && echo "no difference in API schema " || exit 2
- name: Check schema - including warnings
run: invoke schema
run: invoke dev.schema
continue-on-error: true
- name: Extract version for publishing
id: version
@@ -262,9 +262,9 @@ jobs:
run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }}
- name: Start InvenTree Server
run: |
invoke delete-data -f
invoke import-fixtures
invoke server -a 127.0.0.1:12345 &
invoke dev.delete-data -f
invoke dev.import-fixtures
invoke dev.server -a 127.0.0.1:12345 &
invoke wait
- name: Run Tests For `${{ env.wrapper_name }}`
run: |
@@ -302,11 +302,11 @@ jobs:
- name: Data Export Test
uses: ./.github/actions/migration
- name: Test Translations
run: invoke translate
run: invoke dev.translate
- name: Check Migration Files
run: python3 .github/scripts/check_migration_files.py
- name: Coverage Tests
run: invoke test --coverage
run: invoke dev.test --coverage
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # pin@v4.5.0
if: always()
@@ -355,7 +355,7 @@ jobs:
dev-install: true
update: true
- name: Run Tests
run: invoke test
run: invoke dev.test
- name: Data Export Test
uses: ./.github/actions/migration
@@ -399,7 +399,7 @@ jobs:
dev-install: true
update: true
- name: Run Tests
run: invoke test
run: invoke dev.test
- name: Data Export Test
uses: ./.github/actions/migration
@@ -438,7 +438,7 @@ jobs:
dev-install: true
update: true
- name: Run Tests
run: invoke test --migrations --report --coverage
run: invoke dev.test --migrations --report --coverage
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # pin@v4.5.0
if: always()
@@ -525,17 +525,17 @@ jobs:
install: true
update: true
- name: Set up test data
run: invoke setup-test -i
run: invoke dev.setup-test -i
- name: Rebuild thumbnails
run: invoke rebuild-thumbnails
run: invoke int.rebuild-thumbnails
- name: Install dependencies
run: inv frontend-compile
run: invoke int.frontend-compile
- name: Install Playwright Browsers
run: cd src/frontend && npx playwright install --with-deps
- name: Run Playwright tests
id: tests
run: cd src/frontend && npx nyc playwright test
- uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # pin@v4
- uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # pin@v4
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report
@@ -551,6 +551,13 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: pui
- name: Upload bundler info
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: |
cd src/frontend
yarn install
yarn run build
platform_ui_build:
name: Build - UI Platform
@@ -573,7 +580,7 @@ jobs:
run: |
cd src/backend/InvenTree/web/static
zip -r frontend-build.zip web/ web/.vite
- uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # pin@v4.3.6
- uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # pin@v4.4.0
with:
name: frontend-build
path: src/backend/InvenTree/web/static/web
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
zip -r ../frontend-build.zip * .vite
- name: Attest Build Provenance
id: attest
uses: actions/attest-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # pin@v1
uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c # pin@v1
with:
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
+2 -2
View File
@@ -59,7 +59,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: SARIF file
path: results.sarif
@@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
uses: github/codeql-action/upload-sarif@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6
with:
sarif_file: results.sarif
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
npm: true
apt-dependency: gettext
- name: Make Translations
run: invoke translate
run: invoke dev.translate
- name: Remove compiled static files
run: rm -rf src/backend/InvenTree/static
- name: Remove all local changes that are not *.po files
@@ -51,7 +51,7 @@ jobs:
git reset --hard
git reset HEAD~
- name: crowdin action
uses: crowdin/github-action@6ed209d411599a981ccb978df3be9dc9b8a81699 # pin@v2
uses: crowdin/github-action@cf0ccf9a71f614e66e011d461ea11e5dbabb93ca # pin@v2
with:
upload_sources: true
upload_translations: false
+10 -10
View File
@@ -9,61 +9,61 @@
{
"label": "worker",
"type": "shell",
"command": "inv worker",
"command": "invoke int.worker",
"problemMatcher": [],
},
{
"label": "clean-settings",
"type": "shell",
"command": "inv clean-settings",
"command": "invoke int.clean-settings",
"problemMatcher": [],
},
{
"label": "delete-data",
"type": "shell",
"command": "inv delete-data",
"command": "invoke dev.delete-data",
"problemMatcher": [],
},
{
"label": "migrate",
"type": "shell",
"command": "inv migrate",
"command": "invoke migrate",
"problemMatcher": [],
},
{
"label": "server",
"type": "shell",
"command": "inv server",
"command": "invoke dev.server",
"problemMatcher": [],
},
{
"label": "setup-dev",
"type": "shell",
"command": "inv setup-dev",
"command": "invoke dev.setup-dev",
"problemMatcher": [],
},
{
"label": "setup-test",
"type": "shell",
"command": "inv setup-test -i --path dev/inventree-demo-dataset",
"command": "invoke dev.setup-test -i --path dev/inventree-demo-dataset",
"problemMatcher": [],
},
{
"label": "superuser",
"type": "shell",
"command": "inv superuser",
"command": "invoke superuser",
"problemMatcher": [],
},
{
"label": "test",
"type": "shell",
"command": "inv test",
"command": "invoke dev.test",
"problemMatcher": [],
},
{
"label": "update",
"type": "shell",
"command": "inv update",
"command": "invoke update",
"problemMatcher": [],
},
]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2017-2022 InvenTree
Copyright (c) 2017 - InvenTree Developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+16 -2
View File
@@ -81,7 +81,7 @@ InvenTree is designed to be **extensible**, and provides multiple options for **
</details>
<details>
<summary>Client</summary>
<summary>Client - CUI</summary>
<ul>
<li><a href="https://getbootstrap.com/">Bootstrap</a></li>
<li><a href="https://jquery.com/">jQuery</a></li>
@@ -89,13 +89,27 @@ InvenTree is designed to be **extensible**, and provides multiple options for **
</ul>
</details>
<details>
<summary>Client - PUI</summary>
<ul>
<li><a href="https://react.dev/">React</a></li>
<li><a href="https://lingui.dev/">Lingui</a></li>
<li><a href="https://reactrouter.com/">React Router</a></li>
<li><a href="https://tanstack.com/query/">TanStack Query</a></li>
<li><a href="https://github.com/pmndrs/zustand">Zustand</a></li>
<li><a href="https://mantine.dev/">Mantine</a></li>
<li><a href="https://icflorescu.github.io/mantine-datatable/">Mantine Data Table</a></li>
<li><a href="https://codemirror.net/">CodeMirror</a></li>
</ul>
</details>
<details>
<summary>DevOps</summary>
<ul>
<li><a href="https://hub.docker.com/r/inventree/inventree">Docker</a></li>
<li><a href="https://crowdin.com/project/inventree">Crowdin</a></li>
<li><a href="https://app.codecov.io/gh/inventree/InvenTree">Codecov</a></li>
<li><a href="https://app.deepsource.com/gh/inventree/InvenTree">DeepSource</a></li>
<li><a href="https://sonarcloud.io/project/overview?id=inventree_InvenTree">SonarCloud</a></li>
<li><a href="https://packager.io/gh/inventree/InvenTree">Packager.io</a></li>
</ul>
</details>
+8
View File
@@ -27,3 +27,11 @@ flag_management:
statuses:
- type: project
target: 45%
comment:
require_bundle_changes: True
bundle_change_threshold: "1Kb"
bundle_analysis:
warning_threshold: "5%"
status: "informational"
+1 -1
View File
@@ -115,7 +115,7 @@ RUN apk add --no-cache --update nodejs npm yarn
RUN yarn config set network-timeout 600000 -g
COPY src ${INVENTREE_HOME}/src
COPY tasks.py ${INVENTREE_HOME}/tasks.py
RUN cd ${INVENTREE_HOME} && inv frontend-compile
RUN cd ${INVENTREE_HOME} && invoke int.frontend-compile
# InvenTree production image:
# - Copies required files from local directory
+1 -1
View File
@@ -56,7 +56,7 @@ services:
inventree-dev-worker:
image: inventree-dev-image
build: *build_config
command: invoke worker
command: invoke int.worker
depends_on:
- inventree-dev-server
volumes:
+1 -1
View File
@@ -83,7 +83,7 @@ services:
# If you wish to specify a particular InvenTree version, do so here
image: inventree/inventree:${INVENTREE_TAG:-stable}
container_name: inventree-worker
command: invoke worker
command: invoke int.worker
depends_on:
- inventree-server
env_file:
+82 -63
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
}
+6 -2
View File
@@ -3,12 +3,15 @@
# 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_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
@@ -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)"
+8 -5
View File
@@ -2,6 +2,7 @@
#
# 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
@@ -9,12 +10,14 @@ export SETUP_ENVS=PATH,APP_HOME,INVENTREE_MEDIA_ROOT,INVENTREE_STATIC_ROOT,INVEN
if test -f "${APP_HOME}/env/bin/pip"; then
# Check if clear-generated is available
if sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke clear-generated --help" > /dev/null 2>&1; then
echo "# Clearing precompiled files"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke clear-generated"
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 "# Clearing precompiled files - skipping"
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)"
+2 -2
View File
@@ -22,10 +22,10 @@ The API is self-documenting, and the documentation is provided alongside any Inv
### Generating Schema File
If you want to generate the API schema file yourself (for example to use with an external client, use the `invoke schema` command. Run with the `-help` command to see available options.
If you want to generate the API schema file yourself (for example to use with an external client, use the `invoke dev.schema` command. Run with the `-help` command to see available options.
```
invoke schema -help
invoke dev.schema -help
```
## Authentication
+1
View File
@@ -16,6 +16,7 @@ The demo instance has a number of user accounts which you can use to explore the
| Username | Password | Staff Access | Enabled | Description |
| -------- | -------- | ------------ | ------- | ----------- |
| noaccess | youshallnotpass | No | Yes | Can login, but has no permissions |
| allaccess | nolimits | No | Yes | View / create / edit all pages and items |
| reader | readonly | No | Yes | Can view all pages but cannot create, edit or delete database records |
| engineer | partsonly | No | Yes | Can manage parts, view stock, but no access to purchase orders or sales orders |
+1 -1
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
+4 -4
View File
@@ -14,7 +14,7 @@ The React frontend requires its own packages that aren't installed via the usual
#### Docker
Run the following command:
`docker compose run inventree-dev-server invoke frontend-compile`
`docker compose run inventree-dev-server invoke int.frontend-compile`
This will install the required packages for running the React frontend on your InvenTree dev server.
#### Devcontainer
@@ -24,14 +24,14 @@ This will install the required packages for running the React frontend on your I
Open a new terminal from the top menu by clicking `Terminal > New Terminal`
Make sure this terminal is running within the virtual env. The start of the last line should display `(venv)`
Run the command `invoke frontend-compile`. Wait for this to finish
Run the command `invoke int.frontend-compile`. Wait for this to finish
### Running
After finishing the install, you need to launch a frontend server to be able to view the new UI.
Using the previously described ways of running commands, execute the following:
`invoke frontend-dev` in your environment
`invoke dev.frontend-dev` in your environment
This command does not run as a background daemon, and will occupy the window it's ran in.
### Accessing
@@ -40,7 +40,7 @@ When the frontend server is running, it will be available on port 5173.
i.e: https://localhost:5173/
!!! note "Backend Server"
The InvenTree backend server must also be running, for the frontend interface to have something to connect to! To launch a backend server, use the `invoke server` command.
The InvenTree backend server must also be running, for the frontend interface to have something to connect to! To launch a backend server, use the `invoke dev.server` command.
### Debugging
+1 -1
View File
@@ -107,7 +107,7 @@ The background worker process must be started separately to the web-server appli
From the top-level source directory, run the following command from a separate terminal, while the server is already running:
```
invoke worker
invoke int.worker
```
!!! info "Supervisor"
+1
View File
@@ -188,6 +188,7 @@ Configuration of stock item options
{{ globalsetting("STOCK_ENFORCE_BOM_INSTALLATION") }}
{{ globalsetting("STOCK_ALLOW_OUT_OF_STOCK_TRANSFER") }}
{{ globalsetting("TEST_STATION_DATA") }}
{{ globalsetting("TEST_UPLOAD_CREATE_TEMPLATE") }}
### Build Orders
+4 -4
View File
@@ -17,7 +17,7 @@ InvenTree includes a simple server application, suitable for use in a developmen
To run the development server on a local machine, run the command:
```
(env) invoke server
(env) invoke dev.server
```
This will launch the InvenTree web interface at `http://127.0.0.1:8000`.
@@ -25,7 +25,7 @@ This will launch the InvenTree web interface at `http://127.0.0.1:8000`.
A different port can be specified using the `-a` flag:
```
(env) invoke server -a 127.0.0.1:8123
(env) invoke dev.server -a 127.0.0.1:8123
```
Serving on the address `127.0.0.1` means that InvenTree will only be available *on that computer*. The server will be accessible from a web browser on the same computer, but not from any other computers on the local network.
@@ -35,7 +35,7 @@ Serving on the address `127.0.0.1` means that InvenTree will only be available *
To enable access to the InvenTree server from other computers on a local network, you need to know the IP of the computer running the server. For example, if the server IP address is `192.168.120.1`:
```
(env) invoke server -a 192.168.120.1:8000
(env) invoke dev.server -a 192.168.120.1:8000
```
## Background Worker
@@ -52,7 +52,7 @@ source ./env/bin/activate
### Start Background Worker
```
(env) invoke worker
(env) invoke int.worker
```
This will start the background process manager in the current shell.
+3 -3
View File
@@ -202,7 +202,7 @@ Any persistent files generated by the Caddy container (such as certificates, etc
To quickly get started with a [demo dataset](../demo.md), you can run the following command:
```
docker compose run --rm inventree-server invoke setup-test -i
docker compose run --rm inventree-server invoke dev.setup-test -i
```
This will install the InvenTree demo dataset into your instance.
@@ -210,7 +210,7 @@ This will install the InvenTree demo dataset into your instance.
To start afresh (and completely remove the existing database), run the following command:
```
docker compose run --rm inventree-server invoke delete-data
docker compose run --rm inventree-server invoke dev.delete-data
```
## Install custom packages
@@ -247,7 +247,7 @@ index 8adee63..dc3993c 100644
- image: inventree/inventree:${INVENTREE_TAG:-stable}
+ image: inventree/inventree:${INVENTREE_TAG:-stable}-custom
+ pull_policy: never
command: invoke worker
command: invoke int.worker
depends_on:
- inventree-server
```
+3
View File
@@ -6,6 +6,9 @@
{
"pattern": "http://localhost"
},
{
"pattern": "https://localhost:5173/"
},
{
"pattern": "http://127.0.0.1"
},
+1 -1
View File
@@ -1,4 +1,4 @@
mkdocs==1.6.0
mkdocs==1.6.1
mkdocs-macros-plugin>=0.7,<2.0
mkdocs-material>=9.0,<10.0
mkdocs-git-revision-date-localized-plugin>=1.1,<2.0
+15 -15
View File
@@ -280,9 +280,9 @@ mergedeep==1.3.4 \
# via
# mkdocs
# mkdocs-get-deps
mkdocs==1.6.0 \
--hash=sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7 \
--hash=sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512
mkdocs==1.6.1 \
--hash=sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2 \
--hash=sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e
# via
# -r docs/requirements.in
# mkdocs-autorefs
@@ -293,17 +293,17 @@ mkdocs==1.6.0 \
# mkdocs-simple-hooks
# mkdocstrings
# neoteroi-mkdocs
mkdocs-autorefs==1.0.1 \
--hash=sha256:aacdfae1ab197780fb7a2dac92ad8a3d8f7ca8049a9cbe56a4218cd52e8da570 \
--hash=sha256:f684edf847eced40b570b57846b15f0bf57fb93ac2c510450775dcf16accb971
mkdocs-autorefs==1.2.0 \
--hash=sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f \
--hash=sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f
# via mkdocstrings
mkdocs-get-deps==0.2.0 \
--hash=sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c \
--hash=sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134
# via mkdocs
mkdocs-git-revision-date-localized-plugin==1.2.7 \
--hash=sha256:2f83b52b4dad642751a79465f80394672cbad022129286f40d36b03aebee490f \
--hash=sha256:d2b30ccb74ec8e118298758d75ae4b4f02c620daf776a6c92fcbb58f2b78f19f
mkdocs-git-revision-date-localized-plugin==1.2.8 \
--hash=sha256:6e09c308bb27bcf36b211d17b74152ecc2837cdfc351237f70cffc723ef0fd99 \
--hash=sha256:c7ec3b1481ca23134269e84927bd8a5dc1aa359c0e515b832dbd5d25019b5748
# via -r docs/requirements.in
mkdocs-include-markdown-plugin==6.2.2 \
--hash=sha256:d293950f6499d2944291ca7b9bc4a60e652bbfd3e3a42b564f6cceee268694e7 \
@@ -313,9 +313,9 @@ mkdocs-macros-plugin==1.0.5 \
--hash=sha256:f60e26f711f5a830ddf1e7980865bf5c0f1180db56109803cdd280073c1a050a \
--hash=sha256:fe348d75f01c911f362b6d998c57b3d85b505876dde69db924f2c512c395c328
# via -r docs/requirements.in
mkdocs-material==9.5.33 \
--hash=sha256:d23a8b5e3243c9b2f29cdfe83051104a8024b767312dc8fde05ebe91ad55d89d \
--hash=sha256:dbc79cf0fdc6e2c366aa987de8b0c9d4e2bb9f156e7466786ba2fd0f9bf7ffca
mkdocs-material==9.5.34 \
--hash=sha256:1e60ddf716cfb5679dfd65900b8a25d277064ed82d9a53cd5190e3f894df7840 \
--hash=sha256:54caa8be708de2b75167fd4d3b9f3d949579294f49cb242515d4653dbee9227e
# via -r docs/requirements.in
mkdocs-material-extensions==1.3.1 \
--hash=sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443 \
@@ -325,9 +325,9 @@ mkdocs-simple-hooks==0.1.5 \
--hash=sha256:dddbdf151a18723c9302a133e5cf79538be8eb9d274e8e07d2ac3ac34890837c \
--hash=sha256:efeabdbb98b0850a909adee285f3404535117159d5cb3a34f541d6eaa644d50a
# via -r docs/requirements.in
mkdocstrings[python]==0.25.2 \
--hash=sha256:5cf57ad7f61e8be3111a2458b4e49c2029c9cb35525393b179f9c916ca8042dc \
--hash=sha256:9e2cda5e2e12db8bb98d21e3410f3f27f8faab685a24b03b06ba7daa5b92abfc
mkdocstrings[python]==0.26.1 \
--hash=sha256:29738bfb72b4608e8e55cc50fb8a54f325dc7ebd2014e4e3881a49892d5983cf \
--hash=sha256:bb8b8854d6713d5348ad05b069a09f3b79edbc6a0f33a34c6821141adb03fe33
# via
# -r docs/requirements.in
# mkdocstrings-python
+2 -2
View File
@@ -17,6 +17,6 @@ build:
- echo "Generating API schema file"
- pip install -U invoke
- invoke migrate
- invoke export-settings-definitions --filename docs/inventree_settings.json --overwrite
- invoke schema --filename docs/schema.yml --ignore-warnings
- invoke int.export-settings-definitions --filename docs/inventree_settings.json --overwrite
- invoke dev.schema --filename docs/schema.yml --ignore-warnings
- python docs/extract_schema.py docs/schema.yml
+13 -1
View File
@@ -1,13 +1,25 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 249
INVENTREE_API_VERSION = 253
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v253 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7944
- Adjustments for user API endpoints
v252 - 2024-09-13 : https://github.com/inventree/InvenTree/pull/8040
- Add endpoint for listing all known units
v251 - 2024-09-06 : https://github.com/inventree/InvenTree/pull/8018
- Adds "attach_to_model" field to the ReporTemplate model
v250 - 2024-09-04 : https://github.com/inventree/InvenTree/pull/8069
- Fixes 'revision' field definition in Part serializer
v249 - 2024-08-23 : https://github.com/inventree/InvenTree/pull/7978
- Sort status enums
+40 -5
View File
@@ -2,9 +2,13 @@
import logging
from rest_framework import serializers
from django.core.exceptions import PermissionDenied
from django.http import Http404
from rest_framework import exceptions, serializers
from rest_framework.fields import empty
from rest_framework.metadata import SimpleMetadata
from rest_framework.request import clone_request
from rest_framework.utils import model_meta
import common.models
@@ -29,6 +33,40 @@ class InvenTreeMetadata(SimpleMetadata):
so we can perform lookup for ForeignKey related fields.
"""
def determine_actions(self, request, view):
"""Determine the 'actions' available to the user for the given view.
Note that this differs from the standard DRF implementation,
in that we also allow annotation for the 'GET' method.
This allows the client to determine what fields are available,
even if they are only for a read (GET) operation.
See SimpleMetadata.determine_actions for more information.
"""
actions = {}
for method in {'PUT', 'POST', 'GET'} & set(view.allowed_methods):
view.request = clone_request(request, method)
try:
# Test global permissions
if hasattr(view, 'check_permissions'):
view.check_permissions(view.request)
# Test object permissions
if method == 'PUT' and hasattr(view, 'get_object'):
view.get_object()
except (exceptions.APIException, PermissionDenied, Http404):
pass
else:
# If user has appropriate permissions for the view, include
# appropriate metadata about the fields that should be supplied.
serializer = view.get_serializer()
actions[method] = self.get_serializer_info(serializer)
finally:
view.request = request
return actions
def determine_metadata(self, request, view):
"""Overwrite the metadata to adapt to the request user."""
self.request = request
@@ -81,6 +119,7 @@ class InvenTreeMetadata(SimpleMetadata):
# Map the request method to a permission type
rolemap = {
'GET': 'view',
'POST': 'add',
'PUT': 'change',
'PATCH': 'change',
@@ -102,10 +141,6 @@ class InvenTreeMetadata(SimpleMetadata):
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
actions['DELETE'] = {}
# Add a 'VIEW' action if we are allowed to view
if 'GET' in view.allowed_methods and check(user, table, 'view'):
actions['GET'] = {}
metadata['actions'] = actions
except AttributeError:
@@ -79,6 +79,9 @@ class RolePermission(permissions.BasePermission):
# Extract the model name associated with this request
model = get_model_for_view(view)
if model is None:
return True
app_label = model._meta.app_label
model_name = model._meta.model_name
@@ -99,6 +102,17 @@ class IsSuperuser(permissions.IsAdminUser):
return bool(request.user and request.user.is_superuser)
class IsSuperuserOrReadOnly(permissions.IsAdminUser):
"""Allow read-only access to any user, but write access is restricted to superuser users."""
def has_permission(self, request, view):
"""Check if the user is a superuser."""
return bool(
(request.user and request.user.is_superuser)
or request.method in permissions.SAFE_METHODS
)
class IsStaffOrReadOnly(permissions.IsAdminUser):
"""Allows read-only access to any user, but write access is restricted to staff users."""
+31 -2
View File
@@ -403,18 +403,21 @@ class UserSerializer(InvenTreeModelSerializer):
read_only_fields = ['username', 'email']
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
)
last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
)
email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
)
class ExendedUserSerializer(UserSerializer):
class ExtendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer
@@ -437,9 +440,11 @@ class ExendedUserSerializer(UserSerializer):
is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions')
)
is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser')
)
is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active')
)
@@ -464,9 +469,33 @@ class ExendedUserSerializer(UserSerializer):
return super().validate(attrs)
class UserCreateSerializer(ExendedUserSerializer):
class MeUserSerializer(ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options.
Extends the ExtendedUserSerializer.Meta options,
but ensures that certain fields are read-only.
"""
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
'is_active',
'is_staff',
'is_superuser',
]
class UserCreateSerializer(ExtendedUserSerializer):
"""Serializer for creating a new User."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options for the UserCreateSerializer."""
# Prevent creation of users with superuser or staff permissions
read_only_fields = ['groups', 'is_staff', 'is_superuser']
def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser
@@ -1280,6 +1280,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
+2 -2
View File
@@ -263,7 +263,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:
@@ -281,7 +281,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
) # pragma: no cover
if verbose or n >= value:
if verbose and n >= value:
msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover
else:
msg = None
+10 -2
View File
@@ -1039,6 +1039,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)
@@ -1067,7 +1073,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()
@@ -1088,9 +1094,11 @@ class BuildOverallocationTest(BuildAPITest):
'accept_overallocated': 'trim',
},
expected_code=201,
max_query_count=600, # 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
+34
View File
@@ -1,6 +1,7 @@
"""Provides a JSON API for common components."""
import json
from typing import Type
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
@@ -19,6 +20,7 @@ from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
from pint._typing import UnitLike
from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
from rest_framework.permissions import IsAdminUser
@@ -27,6 +29,7 @@ from rest_framework.views import APIView
import common.models
import common.serializers
import InvenTree.conversion
from common.icons import get_icon_packs
from common.settings import get_global_setting
from generic.states.api import urlpattern as generic_states_api_urls
@@ -533,6 +536,36 @@ class CustomUnitDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
class AllUnitList(ListAPI):
"""List of all defined units."""
serializer_class = common.serializers.AllUnitListResponseSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
def get(self, request, *args, **kwargs):
"""Return a list of all available units."""
reg = InvenTree.conversion.get_unit_registry()
all_units = {k: self.get_unit(reg, k) for k in reg}
data = {
'default_system': reg.default_system,
'available_systems': dir(reg.sys),
'available_units': {k: v for k, v in all_units.items() if v},
}
return Response(data)
def get_unit(self, reg, k):
"""Parse a unit from the registry."""
if not hasattr(reg, k):
return None
unit: Type[UnitLike] = getattr(reg, k)
return {
'name': k,
'is_alias': reg.get_name(k) == k,
'compatible_units': [str(a) for a in unit.compatible_units()],
'isdimensionless': unit.dimensionless,
}
class ErrorMessageList(BulkDeleteMixin, ListAPI):
"""List view for server error messages."""
@@ -900,6 +933,7 @@ common_api_urls = [
path('', CustomUnitDetail.as_view(), name='api-custom-unit-detail')
]),
),
path('all/', AllUnitList.as_view(), name='api-all-unit-list'),
path('', CustomUnitList.as_view(), name='api-custom-unit-list'),
]),
),
+8 -14
View File
@@ -1704,20 +1704,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': 'A4',
'choices': report.helpers.report_page_size_options,
},
'REPORT_ENABLE_TEST_REPORT': {
'name': _('Enable Test Reports'),
'description': _('Enable generation of test reports'),
'default': True,
'validator': bool,
},
'REPORT_ATTACH_TEST_REPORT': {
'name': _('Attach Test Reports'),
'description': _(
'When printing a Test Report, attach a copy of the Test Report to the associated Stock Item'
),
'default': False,
'validator': bool,
},
'SERIAL_NUMBER_GLOBALLY_UNIQUE': {
'name': _('Globally Unique Serials'),
'description': _('Serial numbers for stock items must be globally unique'),
@@ -2160,6 +2146,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'TEST_UPLOAD_CREATE_TEMPLATE': {
'name': _('Create Template on Upload'),
'description': _(
'Create a new test template when uploading test data which does not match an existing template'
),
'default': True,
'validator': bool,
},
}
typ = 'inventree'
@@ -382,6 +382,22 @@ class CustomUnitSerializer(DataImportExportSerializerMixin, InvenTreeModelSerial
fields = ['pk', 'name', 'symbol', 'definition']
class AllUnitListResponseSerializer(serializers.Serializer):
"""Serializer for the AllUnitList."""
class Unit(serializers.Serializer):
"""Serializer for the AllUnitListResponseSerializer."""
name = serializers.CharField()
is_alias = serializers.BooleanField()
compatible_units = serializers.ListField(child=serializers.CharField())
isdimensionless = serializers.BooleanField()
default_system = serializers.CharField()
available_systems = serializers.ListField(child=serializers.CharField())
available_units = Unit(many=True)
class ErrorMessageSerializer(InvenTreeModelSerializer):
"""DRF serializer for server error messages."""
+8 -7
View File
@@ -233,9 +233,6 @@ class SettingsTest(InvenTreeTestCase):
report_size_obj = InvenTreeSetting.get_setting_object(
'REPORT_DEFAULT_PAGE_SIZE'
)
report_test_obj = InvenTreeSetting.get_setting_object(
'REPORT_ENABLE_TEST_REPORT'
)
# check settings base fields
self.assertEqual(instance_obj.name, 'Server Instance Name')
@@ -265,7 +262,6 @@ class SettingsTest(InvenTreeTestCase):
# check setting_type
self.assertEqual(instance_obj.setting_type(), 'string')
self.assertEqual(report_test_obj.setting_type(), 'boolean')
self.assertEqual(stale_days.setting_type(), 'integer')
# check as_int
@@ -274,9 +270,6 @@ class SettingsTest(InvenTreeTestCase):
instance_obj.as_int(), 'InvenTree'
) # not an int -> return default
# check as_bool
self.assertEqual(report_test_obj.as_bool(), True)
# check to_native_value
self.assertEqual(stale_days.to_native_value(), 0)
@@ -1507,6 +1500,14 @@ class CustomUnitAPITest(InvenTreeAPITestCase):
for name in invalid_name_values:
self.patch(url, {'name': name}, expected_code=400)
def test_api(self):
"""Test the CustomUnit API."""
response = self.get(reverse('api-all-unit-list'))
self.assertIn('default_system', response.data)
self.assertIn('available_systems', response.data)
self.assertIn('available_units', response.data)
self.assertEqual(len(response.data['available_units']) > 100, True)
class ContentTypeAPITest(InvenTreeAPITestCase):
"""Unit tests for the ContentType API."""
+8 -2
View File
@@ -1049,7 +1049,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)
@@ -1061,6 +1064,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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19 -11
View File
@@ -1517,37 +1517,45 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
except DjangoValidationError as e:
raise ValidationError({'serial_numbers': e.messages})
serials_not_exist = []
serials_allocated = []
serials_not_exist = set()
serials_unavailable = set()
stock_items_to_allocate = []
for serial in data['serials']:
serial = str(serial).strip()
items = stock.models.StockItem.objects.filter(
part=part, serial=serial, quantity=1
)
if not items.exists():
serials_not_exist.append(str(serial))
serials_not_exist.add(str(serial))
continue
stock_item = items[0]
if stock_item.unallocated_quantity() == 1:
stock_items_to_allocate.append(stock_item)
else:
serials_allocated.append(str(serial))
if not stock_item.in_stock:
serials_unavailable.add(str(serial))
continue
if stock_item.unallocated_quantity() < 1:
serials_unavailable.add(str(serial))
continue
# At this point, the serial number is valid, and can be added to the list
stock_items_to_allocate.append(stock_item)
if len(serials_not_exist) > 0:
error_msg = _('No match found for the following serial numbers')
error_msg += ': '
error_msg += ','.join(serials_not_exist)
error_msg += ','.join(sorted(serials_not_exist))
raise ValidationError({'serial_numbers': error_msg})
if len(serials_allocated) > 0:
error_msg = _('The following serial numbers are already allocated')
if len(serials_unavailable) > 0:
error_msg = _('The following serial numbers are unavailable')
error_msg += ': '
error_msg += ','.join(serials_allocated)
error_msg += ','.join(sorted(serials_unavailable))
raise ValidationError({'serial_numbers': error_msg})
+50 -57
View File
@@ -1949,7 +1949,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,
@@ -1959,7 +1959,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()
@@ -1970,7 +1969,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
@@ -2550,7 +2549,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):
@@ -2579,14 +2579,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
@@ -2630,7 +2626,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'
@@ -2649,16 +2645,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
@@ -2689,18 +2705,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."""
@@ -2716,13 +2739,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
@@ -2790,9 +2813,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:
@@ -2803,12 +2823,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()
@@ -2872,12 +2886,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
@@ -2904,12 +2912,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
@@ -2945,12 +2947,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
@@ -2986,9 +2982,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
@@ -3109,12 +3102,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
@@ -4525,7 +4512,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)
@@ -4542,7 +4532,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)
+3 -5
View File
@@ -354,11 +354,9 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
help_text=_('Internal Part Number'),
max_length=100,
)
revision = serializers.CharField(
required=False,
allow_null=True,
help_text=_('Part revision or version number'),
max_length=100,
required=False, default='', allow_blank=True, allow_null=True, max_length=100
)
# Pricing fields
@@ -909,7 +907,7 @@ class PartSerializer(
)
revision = serializers.CharField(
required=False, default='', allow_blank=True, max_length=100
required=False, default='', allow_blank=True, allow_null=True, max_length=100
)
# Annotated fields
+5 -1
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)
+111 -11
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
@@ -112,6 +114,7 @@ class PartPricingTests(InvenTreeTestCase):
def test_invalid_rate(self):
"""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
@@ -143,6 +146,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
@@ -156,19 +160,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
@@ -188,7 +195,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)
@@ -201,6 +209,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
@@ -243,6 +252,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
@@ -252,7 +262,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}',
@@ -273,15 +284,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()
@@ -349,6 +366,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
@@ -373,6 +391,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
@@ -388,6 +407,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.
@@ -411,6 +431,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.
@@ -431,7 +452,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())
@@ -443,5 +464,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'))
+1 -1
View File
@@ -768,7 +768,7 @@ class PluginsRegistry:
for k in self.plugin_settings_keys():
try:
val = get_global_setting(k)
val = get_global_setting(k, create=False)
msg = f'{k}-{val}'
data.update(msg.encode())
+9
View File
@@ -350,6 +350,15 @@ class ReportPrint(GenericAPIView):
output = template.render(instance, request)
if template.attach_to_model:
# Attach the generated report to the model instance
data = output.get_document().write_pdf()
instance.create_attachment(
attachment=ContentFile(data, report_name),
comment=_('Report saved at time of printing'),
upload_user=request.user,
)
# Provide generated report to any interested plugins
for plugin in registry.with_mixin('report'):
try:
@@ -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()
@@ -0,0 +1,23 @@
# Generated by Django 4.2.15 on 2024-09-05 23:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('report', '0027_alter_labeltemplate_model_type_and_more'),
]
operations = [
migrations.AddField(
model_name='labeltemplate',
name='attach_to_model',
field=models.BooleanField(default=False, help_text='Save report output as an attachment against linked model instance when printing', verbose_name='Attach to Model on Print'),
),
migrations.AddField(
model_name='reporttemplate',
name='attach_to_model',
field=models.BooleanField(default=False, help_text='Save report output as an attachment against linked model instance when printing', verbose_name='Attach to Model on Print'),
),
]
+8
View File
@@ -163,6 +163,14 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
editable=False,
)
attach_to_model = models.BooleanField(
default=False,
verbose_name=_('Attach to Model on Print'),
help_text=_(
'Save report output as an attachment against linked model instance when printing'
),
)
def generate_filename(self, context, **kwargs):
"""Generate a filename for this report."""
template_string = Template(self.filename_pattern)
@@ -42,6 +42,7 @@ class ReportSerializerBase(InvenTreeModelSerializer):
'filename_pattern',
'enabled',
'revision',
'attach_to_model',
]
template = InvenTreeAttachmentSerializerField(required=True)
@@ -110,7 +110,7 @@ def uploaded_image(
validate=True,
**kwargs,
):
"""Return a fully-qualified path for an 'uploaded' image.
"""Return raw image data from an 'uploaded' image.
Arguments:
filename: The filename of the image relative to the MEDIA_ROOT directory
@@ -124,7 +124,7 @@ def uploaded_image(
rotate: Optional rotation to apply to the image
Returns:
A fully qualified path to the image
Binary image data to be rendered directly in a <img> tag
Raises:
FileNotFoundError if the file does not exist
+28 -1
View File
@@ -555,8 +555,16 @@ class TestReportTest(PrintTestMixins, ReportTest):
template = ReportTemplate.objects.filter(
enabled=True, model_type='stockitem'
).first()
self.assertIsNotNone(template)
# Ensure that the 'attach_to_model' attribute is initially False
template.attach_to_model = False
template.save()
template.refresh_from_db()
self.assertFalse(template.attach_to_model)
url = reverse(self.print_url)
# Try to print without providing a valid StockItem
@@ -568,18 +576,37 @@ class TestReportTest(PrintTestMixins, ReportTest):
# Now print with a valid StockItem
item = StockItem.objects.first()
n = item.attachments.count()
response = self.post(
url, {'template': template.pk, 'items': [item.pk]}, expected_code=201
)
# There should be a link to the generated PDF
self.assertEqual(response.data['output'].startswith('/media/report/'), True)
self.assertTrue(response.data['output'].startswith('/media/report/'))
self.assertTrue(response.data['output'].endswith('.pdf'))
# By default, this should *not* have created an attachment against this stockitem
self.assertEqual(n, item.attachments.count())
self.assertFalse(
Attachment.objects.filter(model_id=item.pk, model_type='stockitem').exists()
)
# Now try again, but attach the generated PDF to the StockItem
template.attach_to_model = True
template.save()
response = self.post(
url, {'template': template.pk, 'items': [item.pk]}, expected_code=201
)
# A new attachment should have been created
self.assertEqual(n + 1, item.attachments.count())
attachment = item.attachments.order_by('-pk').first()
# The attachment should be a PDF
self.assertTrue(attachment.attachment.name.endswith('.pdf'))
def test_mdl_build(self):
"""Test the Build model."""
self.run_print_test(Build, 'build', label=False)
+28 -33
View File
@@ -2278,14 +2278,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)
@@ -2296,19 +2298,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):
@@ -2429,28 +2427,25 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
super().clean()
# If this test result corresponds to a template, check the requirements of the template
template = self.template
try:
template = self.template
except PartModels.PartTestTemplate.DoesNotExist:
template = None
if template is None:
# Fallback if there is no matching template
for template in self.stock_item.part.getTestTemplates():
if self.key == template.key:
break
if not template:
raise ValidationError({'template': _('Test template does not exist')})
if template:
if template.requires_value and not self.value:
raise ValidationError({
'value': _('Value must be provided for this test')
})
if template.requires_value and not self.value:
raise ValidationError({'value': _('Value must be provided for this test')})
if template.requires_attachment and not self.attachment:
raise ValidationError({
'attachment': _('Attachment must be uploaded for this test')
})
if template.requires_attachment and not self.attachment:
raise ValidationError({
'attachment': _('Attachment must be uploaded for this test')
})
if choices := template.get_choices():
if self.value not in choices:
raise ValidationError({'value': _('Invalid value for this test')})
if choices := template.get_choices():
if self.value not in choices:
raise ValidationError({'value': _('Invalid value for this test')})
@property
def key(self):
+2 -2
View File
@@ -268,13 +268,13 @@ class StockItemTestResultSerializer(
).first():
data['template'] = template
else:
elif get_global_setting('TEST_UPLOAD_CREATE_TEMPLATE', False):
logger.debug(
"No matching test template found for '%s' - creating a new template",
test_name,
)
# Create a new test template based on the provided dasta
# Create a new test template based on the provided data
data['template'] = part_models.PartTestTemplate.objects.create(
part=stock_item.part, test_name=test_name
)
@@ -54,19 +54,15 @@
</div>
{% endif %}
<!-- Document / label menu -->
{% if test_report_enabled or labels_enabled %}
<div class='btn-group' role='group'>
<button id='document-options' title='{% trans "Printing actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-print'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
{% if labels_enabled %}
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %}
{% if test_report_enabled %}
<li><a class='dropdown-item' href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Print Report" %}</a></li>
</ul>
</div>
{% endif %}
<!-- Stock adjustment menu -->
{% if user_owns_item %}
+8
View File
@@ -1716,6 +1716,14 @@ class StockTestResultTest(StockAPITestCase):
'notes': 'I guess there was just too much pressure?',
}
# First, test with TEST_UPLOAD_CREATE_TEMPLATE set to False
InvenTreeSetting.set_setting('TEST_UPLOAD_CREATE_TEMPLATE', False, self.user)
response = self.post(url, data, expected_code=400)
# Again, with the setting enabled
InvenTreeSetting.set_setting('TEST_UPLOAD_CREATE_TEMPLATE', True, self.user)
response = self.post(url, data, expected_code=201)
# Check that a new test template has been created
@@ -18,8 +18,6 @@
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_LOG_ERRORS" icon="fa-exclamation-circle" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ATTACH_TEST_REPORT" icon="fa-file-upload" %}
</tbody>
</table>
@@ -25,6 +25,7 @@
{% include "InvenTree/settings/setting.html" with key="STOCK_ENFORCE_BOM_INSTALLATION" icon="fa-check-circle" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_OUT_OF_STOCK_TRANSFER" icon="fa-dolly" %}
{% include "InvenTree/settings/setting.html" with key="TEST_STATION_DATA" icon="fa-network-wired" %}
{% include "InvenTree/settings/setting.html" with key="TEST_UPLOAD_CREATE_TEMPLATE" %}
</tbody>
</table>
@@ -4,7 +4,6 @@
{% plugins_enabled as plugins_enabled %}
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
{% settings_value 'RETURNORDER_ENABLED' as return_order_enabled %}
{% settings_value "REPORT_ENABLE" as report_enabled %}
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}

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