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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+10
-10
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
{
|
||||
"pattern": "http://localhost"
|
||||
},
|
||||
{
|
||||
"pattern": "https://localhost:5173/"
|
||||
},
|
||||
{
|
||||
"pattern": "http://127.0.0.1"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
+23
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user