mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-23 05:06:32 +00:00
.devcontainer
.github
.gitignore.pkgr.yml.pre-commit-config.yaml.vscode
CONTRIBUTING.mdDockerfileInvenTree
InvenTree
api.pyapi_version.pyapps.pybackends.pyconfig.pycontext.pyconversion.pyexceptions.pyformat.pyforms.pyhelpers.pyhelpers_mixin.pyhelpers_model.pylocales.pymagic_login.py
management
metadata.pymiddleware.pymixins.pymodels.pypermissions.pyready.pyserializers.pysettings.pysocial_auth_urls.pytasks.pytemplatetags
test_api.pytest_api_version.pytest_middleware.pytest_urls.pytest_views.pytests.pytracing.pyurls.pyversion.py_testfolder
build
admin.pyapi.pytest_api.pytest_build.pytests.py
migrations
models.pyserializers.pytasks.pytemplates
build
common
company
config_template.yamlgeneric
label
locale
bg
LC_MESSAGES
cs
LC_MESSAGES
da
LC_MESSAGES
de
LC_MESSAGES
el
LC_MESSAGES
en
LC_MESSAGES
es
LC_MESSAGES
es_MX
LC_MESSAGES
fa
LC_MESSAGES
fi
LC_MESSAGES
fr
LC_MESSAGES
he
LC_MESSAGES
hi
LC_MESSAGES
hu
LC_MESSAGES
id
LC_MESSAGES
it
LC_MESSAGES
ja
LC_MESSAGES
ko
LC_MESSAGES
lv
LC_MESSAGES
nl
LC_MESSAGES
no
LC_MESSAGES
pl
LC_MESSAGES
pt
LC_MESSAGES
pt_br
LC_MESSAGES
ru
LC_MESSAGES
sk
LC_MESSAGES
sl
LC_MESSAGES
sr
LC_MESSAGES
sv
LC_MESSAGES
th
LC_MESSAGES
tr
LC_MESSAGES
vi
LC_MESSAGES
zh
LC_MESSAGES
zh_Hans
LC_MESSAGES
zh_hant
LC_MESSAGES
machine
__init__.pyadmin.pyapi.pyapps.pymachine_type.py
machine_types
migrations
models.pyregistry.pyserializers.pytest_api.pytests.pyorder
part
admin.pyapi.pyfilters.pytest_api.pytest_bom_export.pytest_category.pytest_migrations.pytest_part.pytest_views.py
fixtures
migrations
0020_auto_20190908_0404.py0039_auto_20200515_1127.py0109_auto_20230517_1048.py0112_auto_20230525_1606.py0120_parttesttemplate_key.py0121_auto_20240207_0344.py0122_parttesttemplate_enabled.py
models.pyserializers.pytemplates
part
plugin
admin.pyapi.pyapps.pyhelpers.pyinstaller.pyserializers.pytest_api.pytest_plugin.pyurls.pyviews.py
base
action
barcodes
event
integration
label
locate
builtin
barcodes
integration
labels
machine
migrations
models.pyplugin.pyregistry.pysamples
integration
report
stock
admin.pyapi.pyfilters.py
fixtures
migrations
0012_auto_20190908_0405.py0022_auto_20200217_1109.py0105_stockitemtestresult_template.py0106_auto_20240207_0353.py0107_remove_stockitemtestresult_test_and_more.py0108_auto_20240219_0252.py0109_add_additional_test_fields.py
models.pyserializers.pytemplates
test_api.pytest_migrations.pytest_views.pytests.pytemplates
InvenTree
js
translated
socialaccount
users
web
ci
contrib/packager.io
docker.dev.envdocker
.envCaddyfiledocker-compose.sqlite.ymldocker-compose.ymlinit.shinstall_build_packages.shnginx.dev.confrequirements.txt
docs
.gitignoreextract_schema.pymkdocs.ymlmlc_config.jsonrequirements.txt
package-lock.jsonpackage.jsonreadthedocs.ymlrequirements-dev.txtrequirements.inrequirements.txt_includes
ci
docs
api
assets
images
concepts
develop
extend
faq.mdpart
releases
report
start
advanced.mdbare_prod.mdconfig.mddocker.mddocker_dev.mddocker_install.mddocker_prod.mdinstall.mdintro.mdserving_files.md
stock
stylesheets
src/frontend
.linguircpackage.json
tasks.pyyarn.locksrc
App.tsxmain.tsx
vite.config.tsyarn.lockcomponents
DashboardItemProxy.tsx
buttons
details
editors
TemplateEditor
forms
images
items
ActionDropdown.tsxAttachmentLink.tsxInfoItem.tsxLanguageSelect.tsxProgressBar.tsxUnavailableIndicator.tsxYesNoButton.tsx
modals
nav
BreadcrumbList.tsxDetailDrawer.tsxHeader.tsxMainMenu.tsxNotificationDrawer.tsxPanelGroup.tsxPartCategoryTree.tsxSearchDrawer.tsxStockLocationTree.tsx
render
settings
tables
contexts
defaults
enums
forms
AttachmentForms.tsxCommonForms.tsxCompanyForms.tsxPartForms.tsxPurchaseOrderForms.tsxSalesOrderForms.tsxStockForms.tsx
functions
hooks
locales
bg
cs
da
de
el
en
es-mx
es
fa
fi
fr
he
hi
hu
id
it
ja
ko
lv
nl
no
pl
pseudo-LOCALE
pt-br
pt
ru
sk
sl
sr
sv
th
tr
vi
zh-hans
zh-hant
zh
pages
router.tsxstates
tables
Column.tsxColumnRenderers.tsxColumnSelect.tsxDownloadAction.tsxFilter.tsxFilterSelectDrawer.tsxInvenTreeTable.tsxRowActions.tsxSearch.tsxTableHoverCard.tsx
bom
build
company
general
machine
notifications
part
ParametricPartTable.tsxPartCategoryTable.tsxPartCategoryTemplateTable.tsxPartParameterTable.tsxPartParameterTemplateTable.tsxPartTable.tsxPartTestTemplateTable.tsxPartThumbTable.tsxPartVariantTable.tsxRelatedPartTable.tsx
plugin
purchasing
ManufacturerPartParameterTable.tsxManufacturerPartTable.tsxPurchaseOrderLineItemTable.tsxPurchaseOrderTable.tsxSupplierPartTable.tsx
sales
settings
CurrencyTable.tsxCustomUnitsTable.tsxErrorTable.tsxFailedTasksTable.tsxGroupTable.tsxPendingTasksTable.tsxProjectCodeTable.tsxScheduledTasksTable.tsxTemplateTable.tsxUserTable.tsx
stock
views
@@ -1,16 +1,11 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.241.1/containers/python-3
|
||||
{
|
||||
"name": "InvenTree",
|
||||
"build": {
|
||||
"dockerfile": "../Dockerfile",
|
||||
"context": "..",
|
||||
"target": "devcontainer",
|
||||
"args": {
|
||||
"base_image": "mcr.microsoft.com/vscode/devcontainers/base:alpine-3.18",
|
||||
"workspace": "${containerWorkspaceFolder}"
|
||||
}
|
||||
},
|
||||
"name": "InvenTree devcontainer",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "inventree",
|
||||
"overrideCommand": true,
|
||||
"workspaceFolder": "/home/inventree/",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
@@ -42,40 +37,27 @@
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [5173, 8000],
|
||||
"forwardPorts": [5173, 8000, 8080],
|
||||
"portsAttributes": {
|
||||
"5173": {
|
||||
"label": "Vite server"
|
||||
"label": "Vite Server"
|
||||
},
|
||||
"8000": {
|
||||
"label": "InvenTree server"
|
||||
"label": "InvenTree Server"
|
||||
},
|
||||
"8080": {
|
||||
"label": "mkdocs server"
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "./.devcontainer/postCreateCommand.sh ${containerWorkspaceFolder}",
|
||||
"postCreateCommand": ".devcontainer/postCreateCommand.sh",
|
||||
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "vscode",
|
||||
"containerUser": "vscode",
|
||||
"features": {
|
||||
"git": "os-provided"
|
||||
},
|
||||
|
||||
"remoteEnv": {
|
||||
// InvenTree config
|
||||
"INVENTREE_DEBUG": "True",
|
||||
"INVENTREE_LOG_LEVEL": "INFO",
|
||||
"INVENTREE_DB_ENGINE": "sqlite3",
|
||||
"INVENTREE_DB_NAME": "${containerWorkspaceFolder}/dev/database.sqlite3",
|
||||
"INVENTREE_MEDIA_ROOT": "${containerWorkspaceFolder}/dev/media",
|
||||
"INVENTREE_STATIC_ROOT": "${containerWorkspaceFolder}/dev/static",
|
||||
"INVENTREE_BACKUP_DIR": "${containerWorkspaceFolder}/dev/backup",
|
||||
"INVENTREE_CONFIG_FILE": "${containerWorkspaceFolder}/dev/config.yaml",
|
||||
"INVENTREE_SECRET_KEY_FILE": "${containerWorkspaceFolder}/dev/secret_key.txt",
|
||||
"INVENTREE_PLUGINS_ENABLED": "True",
|
||||
"INVENTREE_PLUGIN_DIR": "${containerWorkspaceFolder}/dev/plugins",
|
||||
"INVENTREE_PLUGIN_FILE": "${containerWorkspaceFolder}/dev/plugins.txt",
|
||||
|
||||
// Python config
|
||||
"PIP_USER": "no",
|
||||
|
42
.devcontainer/docker-compose.yml
Normal file
42
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:13
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- 5432/tcp
|
||||
volumes:
|
||||
- inventreedatabase:/var/lib/postgresql/data:z
|
||||
environment:
|
||||
POSTGRES_DB: inventree
|
||||
POSTGRES_USER: inventree_user
|
||||
POSTGRES_PASSWORD: inventree_password
|
||||
|
||||
inventree:
|
||||
build:
|
||||
context: ..
|
||||
target: dev
|
||||
args:
|
||||
base_image: "mcr.microsoft.com/vscode/devcontainers/base:alpine-3.18"
|
||||
data_dir: "dev"
|
||||
volumes:
|
||||
- ../:/home/inventree:z
|
||||
|
||||
environment:
|
||||
INVENTREE_DEBUG: True
|
||||
INVENTREE_DB_ENGINE: postgresql
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_DB_HOST: db
|
||||
INVENTREE_DB_USER: inventree_user
|
||||
INVENTREE_DB_PASSWORD: inventree_password
|
||||
INVENTREE_PLUGINS_ENABLED: True
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
INVENTREE_CORS_ORIGIN_ALLOW_ALL: True
|
||||
INVENTREE_PY_ENV: /home/inventree/dev/venv
|
||||
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
inventreedatabase:
|
@@ -1,21 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Avoiding Dubious Ownership in Dev Containers for setup commands that use git
|
||||
# Note that the local workspace directory is passed through as the first argument $1
|
||||
git config --global --add safe.directory $1
|
||||
|
||||
# create folders
|
||||
mkdir -p $1/dev/{commandhistory,plugins}
|
||||
cd $1
|
||||
git config --global --add safe.directory /home/inventree
|
||||
|
||||
# create venv
|
||||
python3 -m venv $1/dev/venv
|
||||
. $1/dev/venv/bin/activate
|
||||
python3 -m venv /home/inventree/dev/venv --system-site-packages --upgrade-deps
|
||||
. /home/inventree/dev/venv/bin/activate
|
||||
|
||||
# setup InvenTree server
|
||||
pip install invoke
|
||||
invoke update
|
||||
# Run initial InvenTree server setup
|
||||
invoke update -s
|
||||
|
||||
# Configure dev environment
|
||||
invoke setup-dev
|
||||
|
||||
# Install required frontend packages
|
||||
invoke frontend-install
|
||||
|
||||
# remove existing gitconfig created by "Avoiding Dubious Ownership" step
|
||||
|
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,4 +1,5 @@
|
||||
github: inventree
|
||||
ko_fi: inventree
|
||||
patreon: inventree
|
||||
polar: inventree
|
||||
custom: [paypal.me/inventree]
|
||||
|
13
.github/actions/setup/action.yaml
vendored
13
.github/actions/setup/action.yaml
vendored
@@ -49,11 +49,14 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -m pip install -U pip
|
||||
pip3 install invoke wheel
|
||||
pip3 install invoke wheel uv
|
||||
- name: Set the VIRTUAL_ENV variable for uv to work
|
||||
run: echo "VIRTUAL_ENV=${Python_ROOT_DIR}" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
- name: Install Specific Python Dependencies
|
||||
if: ${{ inputs.pip-dependency }}
|
||||
shell: bash
|
||||
run: pip3 install ${{ inputs.pip-dependency }}
|
||||
run: uv pip install ${{ inputs.pip-dependency }}
|
||||
|
||||
# NPM installs
|
||||
- name: Install node.js ${{ env.node_version }}
|
||||
@@ -79,12 +82,12 @@ runs:
|
||||
- name: Install dev requirements
|
||||
if: ${{ inputs.dev-install == 'true' ||inputs.install == 'true' }}
|
||||
shell: bash
|
||||
run: pip install -r requirements-dev.txt
|
||||
run: uv pip install -r requirements-dev.txt
|
||||
- name: Run invoke install
|
||||
if: ${{ inputs.install == 'true' }}
|
||||
shell: bash
|
||||
run: invoke install
|
||||
run: invoke install --uv
|
||||
- name: Run invoke update
|
||||
if: ${{ inputs.update == 'true' }}
|
||||
shell: bash
|
||||
run: invoke update
|
||||
run: invoke update --uv
|
||||
|
36
.github/dependabot.yml
vendored
Normal file
36
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /docker
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /docs
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/frontend
|
||||
schedule:
|
||||
interval: daily
|
18
.github/workflows/check_translations.yaml
vendored
18
.github/workflows/check_translations.yaml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- l10
|
||||
|
||||
env:
|
||||
python_version: 3.9
|
||||
|
||||
jobs:
|
||||
|
||||
check:
|
||||
@@ -21,22 +24,15 @@ jobs:
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
INVENTREE_BACKUP_DIR: ./backup
|
||||
python_version: 3.9
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Set Up Python ${{ env.python_version }}
|
||||
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
python-version: ${{ env.python_version }}
|
||||
cache: 'pip'
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install gettext
|
||||
pip3 install invoke
|
||||
invoke install
|
||||
install: true
|
||||
apt-dependency: gettext
|
||||
- name: Test Translations
|
||||
run: invoke translate
|
||||
- name: Check Migration Files
|
||||
|
57
.github/workflows/docker.yaml
vendored
57
.github/workflows/docker.yaml
vendored
@@ -24,9 +24,14 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
paths-filter:
|
||||
permissions:
|
||||
contents: read # for dorny/paths-filter to fetch a list of changed files
|
||||
pull-requests: read # for dorny/paths-filter to read pull requests
|
||||
name: Filter
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -35,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -45,12 +50,12 @@ jobs:
|
||||
- docker-compose.yml
|
||||
- docker.dev.env
|
||||
- Dockerfile
|
||||
- InvenTree/settings.py
|
||||
- requirements.txt
|
||||
|
||||
- tasks.py
|
||||
|
||||
# Build the docker image
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.docker == 'true' || github.event_name == 'release' || github.event_name == 'push'
|
||||
permissions:
|
||||
@@ -59,7 +64,9 @@ jobs:
|
||||
id-token: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
python_version: 3.9
|
||||
python_version: "3.11"
|
||||
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
@@ -74,10 +81,17 @@ jobs:
|
||||
python3 ci/version_check.py
|
||||
echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV
|
||||
- name: Test Docker Image
|
||||
id: test-docker
|
||||
run: |
|
||||
docker build . --target production --tag inventree-test
|
||||
docker run --rm inventree-test invoke --version
|
||||
docker run --rm inventree-test invoke --list
|
||||
docker run --rm inventree-test gunicorn --version
|
||||
docker run --rm inventree-test pg_dump --version
|
||||
- name: Build Docker Image
|
||||
# Build the development docker image (using docker-compose.yml)
|
||||
run: |
|
||||
docker-compose build --no-cache
|
||||
run: docker-compose build --no-cache
|
||||
- name: Update Docker Image
|
||||
run: |
|
||||
docker-compose run inventree-dev-server invoke update
|
||||
@@ -102,6 +116,9 @@ jobs:
|
||||
docker-compose run inventree-dev-server invoke test --disable-pty
|
||||
docker-compose run inventree-dev-server invoke test --migrations --disable-pty
|
||||
docker-compose down
|
||||
- name: Clean up test folder
|
||||
run: |
|
||||
rm -rf InvenTree/_testfolder
|
||||
- name: Set up QEMU
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # pin@v3.0.0
|
||||
@@ -111,8 +128,16 @@ jobs:
|
||||
- name: Set up cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 # pin@v3.1.2
|
||||
- name: Check if Dockerhub login is required
|
||||
id: docker_login
|
||||
run: |
|
||||
if [ -z "${{ secrets.DOCKER_USERNAME }}" ]; then
|
||||
echo "skip_dockerhub_login=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "skip_dockerhub_login=false" >> $GITHUB_ENV
|
||||
fi
|
||||
- name: Login to Dockerhub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true'
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # pin@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
@@ -129,16 +154,16 @@ jobs:
|
||||
- name: Extract Docker metadata
|
||||
if: github.event_name != 'pull_request'
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # pin@v5.0.0
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # pin@v5.5.1
|
||||
with:
|
||||
images: |
|
||||
inventree/inventree
|
||||
ghcr.io/inventree/inventree
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and Push
|
||||
id: build-and-push
|
||||
- name: Push Docker Images
|
||||
id: push-docker
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # pin@v5.0.0
|
||||
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # pin@v5.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -150,9 +175,3 @@ jobs:
|
||||
build-args: |
|
||||
commit_hash=${{ env.git_commit_hash }}
|
||||
commit_date=${{ env.git_commit_date }}
|
||||
|
||||
- name: Sign the published image
|
||||
if: ${{ false }} # github.event_name != 'pull_request'
|
||||
env:
|
||||
COSIGN_EXPERIMENTAL: "true"
|
||||
run: cosign sign ${{ steps.meta.outputs.tags }}@${{ steps.build-and-push.outputs.digest }}
|
||||
|
119
.github/workflows/qc_checks.yaml
vendored
119
.github/workflows/qc_checks.yaml
vendored
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
env:
|
||||
python_version: 3.9
|
||||
node_version: 16
|
||||
node_version: 18
|
||||
# The OS version must be set per job
|
||||
server_start_sleep: 60
|
||||
|
||||
@@ -20,6 +20,7 @@ env:
|
||||
INVENTREE_MEDIA_ROOT: ../test_inventree_media
|
||||
INVENTREE_STATIC_ROOT: ../test_inventree_static
|
||||
INVENTREE_BACKUP_DIR: ../test_inventree_backup
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
|
||||
jobs:
|
||||
paths-filter:
|
||||
@@ -30,10 +31,11 @@ jobs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
migrations: ${{ steps.filter.outputs.migrations }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
api: ${{ steps.filter.outputs.api }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -44,6 +46,8 @@ jobs:
|
||||
migrations:
|
||||
- '**/migrations/**'
|
||||
- '.github/workflows**'
|
||||
api:
|
||||
- 'InvenTree/InvenTree/api_version.py'
|
||||
frontend:
|
||||
- 'src/frontend/**'
|
||||
|
||||
@@ -83,7 +87,7 @@ jobs:
|
||||
python-version: ${{ env.python_version }}
|
||||
cache: 'pip'
|
||||
- name: Run pre-commit Checks
|
||||
uses: pre-commit/action@646c83fcd040023954eafda54b4db0192ce70507 # pin@v3.0.0
|
||||
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # pin@v3.0.1
|
||||
- name: Check Version
|
||||
run: |
|
||||
pip install requests
|
||||
@@ -105,11 +109,100 @@ jobs:
|
||||
- name: Check Config
|
||||
run: |
|
||||
pip install pyyaml
|
||||
pip install -r docs/requirements.txt
|
||||
python docs/ci/check_mkdocs_config.py
|
||||
- name: Check Links
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@5c5dfc0ac2e225883c0e5f03a85311ec2830d368 # v1
|
||||
with:
|
||||
folder-path: docs
|
||||
config-file: docs/mlc_config.json
|
||||
check-modified-files-only: 'yes'
|
||||
use-quiet-mode: 'yes'
|
||||
|
||||
schema:
|
||||
name: Tests - API Schema Documentation
|
||||
runs-on: ubuntu-20.04
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.server == 'true'
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
|
||||
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
|
||||
INVENTREE_ADMIN_USER: testuser
|
||||
INVENTREE_ADMIN_PASSWORD: testpassword
|
||||
INVENTREE_ADMIN_EMAIL: test@test.com
|
||||
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
|
||||
INVENTREE_PYTHON_TEST_USERNAME: testuser
|
||||
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Export API Documentation
|
||||
run: invoke schema --ignore-warnings --filename InvenTree/schema.yml
|
||||
- name: Upload schema
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
|
||||
with:
|
||||
name: schema.yml
|
||||
path: InvenTree/schema.yml
|
||||
- name: Download public schema
|
||||
if: needs.paths-filter.outputs.api == 'false'
|
||||
run: |
|
||||
pip install linkcheckmd requests
|
||||
python -m linkcheckmd docs --recurse
|
||||
pip install requests >/dev/null 2>&1
|
||||
version="$(python3 ci/version_check.py only_version 2>&1)"
|
||||
echo "Version: $version"
|
||||
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
|
||||
echo "URL: $url"
|
||||
curl -s -o api.yaml $url
|
||||
echo "Downloaded api.yaml"
|
||||
- name: Check for differences in API Schema
|
||||
if: needs.paths-filter.outputs.api == 'false'
|
||||
run: |
|
||||
diff --color -u InvenTree/schema.yml api.yaml
|
||||
diff -u InvenTree/schema.yml api.yaml && echo "no difference in API schema " || exit 2
|
||||
- name: Check schema - including warnings
|
||||
run: invoke schema
|
||||
continue-on-error: true
|
||||
- name: Extract version for publishing
|
||||
id: version
|
||||
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
|
||||
run: |
|
||||
pip install requests >/dev/null 2>&1
|
||||
version="$(python3 ci/version_check.py only_version 2>&1)"
|
||||
echo "Version: $version"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
schema-push:
|
||||
name: Push new schema
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [paths-filter, schema]
|
||||
if: needs.schema.result == 'success' && github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true' && github.repository_owner == 'inventree'
|
||||
env:
|
||||
version: ${{ needs.schema.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
with:
|
||||
repository: inventree/schema
|
||||
token: ${{ secrets.SCHEMA_PAT }}
|
||||
- name: Download schema artifact
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
|
||||
with:
|
||||
name: schema.yml
|
||||
- name: Move schema to correct location
|
||||
run: |
|
||||
echo "Version: $version"
|
||||
mkdir export/${version}
|
||||
mv schema.yml export/${version}/api.yaml
|
||||
- uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # v5.0.0
|
||||
with:
|
||||
commit_message: "Update API schema for ${version}"
|
||||
|
||||
python:
|
||||
name: Tests - inventree-python
|
||||
@@ -124,9 +217,10 @@ jobs:
|
||||
INVENTREE_ADMIN_USER: testuser
|
||||
INVENTREE_ADMIN_PASSWORD: testpassword
|
||||
INVENTREE_ADMIN_EMAIL: test@test.com
|
||||
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
|
||||
INVENTREE_PYTHON_TEST_SERVER: http://127.0.0.1:12345
|
||||
INVENTREE_PYTHON_TEST_USERNAME: testuser
|
||||
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
|
||||
INVENTREE_SITE_URL: http://127.0.0.1:12345
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
@@ -220,7 +314,7 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils libpq-dev
|
||||
pip-dependency: psycopg2 django-redis>=5.0.0
|
||||
pip-dependency: psycopg django-redis>=5.0.0
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Run Tests
|
||||
@@ -302,7 +396,7 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils libpq-dev
|
||||
pip-dependency: psycopg2
|
||||
pip-dependency: psycopg
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Run Tests
|
||||
@@ -357,6 +451,13 @@ jobs:
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
- name: 0.13.5 Database
|
||||
run: |
|
||||
rm /home/runner/work/InvenTree/db.sqlite3
|
||||
cp test-db/stable_0.13.5.sqlite3 /home/runner/work/InvenTree/db.sqlite3
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
platform_ui:
|
||||
name: Tests - Platform UI
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -406,7 +507,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend && yarn install
|
||||
- name: Build frontend
|
||||
run: cd src/frontend && npm run build
|
||||
run: cd src/frontend && npm run compile && npm run build
|
||||
- name: Zip frontend
|
||||
run: |
|
||||
cd InvenTree/web/static
|
||||
|
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -37,12 +37,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend && yarn install
|
||||
- name: Build frontend
|
||||
run: cd src/frontend && npm run build
|
||||
run: cd src/frontend && npm run compile && npm run build
|
||||
- name: Zip frontend
|
||||
run: |
|
||||
cd InvenTree/web/static/web
|
||||
zip -r ../frontend-build.zip *
|
||||
- uses: svenstaro/upload-release-action@1beeb572c19a9242f4361f4cee78f8e0d9aec5df # pin@2.7.0
|
||||
- uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # pin@2.9.0
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: InvenTree/web/static/frontend-build.zip
|
||||
|
72
.github/workflows/scorecard.yml
vendored
Normal file
72
.github/workflows/scorecard.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '32 0 * * 0'
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
# Uncomment the permissions below if installing in a private repository.
|
||||
# contents: read
|
||||
# actions: read
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@e38b1902ae4f44df626f11ba0734b14fb91f8f86 # v2.1.2
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecard on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
|
||||
# Public repositories:
|
||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||
# - Allows the repository to include the Scorecard badge.
|
||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||
# For private repositories:
|
||||
# - `publish_results` will always be set to `false`, regardless
|
||||
# of the value entered here.
|
||||
publish_results: false
|
||||
|
||||
# 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@3cea5372237819ed00197afe530f5a7ea3e805c8 # v3.1.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2.2.4
|
||||
with:
|
||||
sarif_file: results.sarif
|
3
.github/workflows/stale.yml
vendored
3
.github/workflows/stale.yml
vendored
@@ -5,6 +5,9 @@ on:
|
||||
schedule:
|
||||
- cron: '24 11 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
|
23
.github/workflows/translations.yml
vendored
23
.github/workflows/translations.yml
vendored
@@ -5,6 +5,10 @@ on:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
python_version: 3.9
|
||||
node_version: 18
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -18,24 +22,17 @@ jobs:
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
INVENTREE_BACKUP_DIR: ./backup
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Set up Node 16
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # pin to v3.8.2
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
pip3 install invoke
|
||||
invoke install
|
||||
install: true
|
||||
npm: true
|
||||
apt-dependency: gettext
|
||||
- name: Make Translations
|
||||
run: invoke translate
|
||||
- name: Commit files
|
||||
|
17
.gitignore
vendored
17
.gitignore
vendored
@@ -39,15 +39,6 @@ local_settings.py
|
||||
# Files used for testing
|
||||
inventree-demo-dataset/
|
||||
inventree-data/
|
||||
dummy_image.*
|
||||
_tmp.csv
|
||||
InvenTree/label.pdf
|
||||
InvenTree/label.png
|
||||
InvenTree/part_image_123abc.png
|
||||
label.pdf
|
||||
label.png
|
||||
InvenTree/my_special*
|
||||
_tests*.txt
|
||||
|
||||
# Local static and media file storage (only when running in development mode)
|
||||
inventree_media
|
||||
@@ -70,6 +61,7 @@ secret_key.txt
|
||||
.idea/
|
||||
*.code-workspace
|
||||
.bash_history
|
||||
.DS_Store
|
||||
|
||||
# https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
.vscode/*
|
||||
@@ -107,5 +99,12 @@ InvenTree/plugins/
|
||||
*.mo
|
||||
messages.ts
|
||||
|
||||
# Generated API schema file
|
||||
api.yaml
|
||||
|
||||
# web frontend (static files)
|
||||
InvenTree/web/static
|
||||
|
||||
# Generated docs files
|
||||
docs/docs/api/*.yml
|
||||
docs/docs/api/schema/*.yml
|
||||
|
@@ -19,9 +19,9 @@ before:
|
||||
- contrib/packager.io/before.sh
|
||||
dependencies:
|
||||
- curl
|
||||
- python3.9
|
||||
- python3.9-venv
|
||||
- python3.9-dev
|
||||
- "python3.9 | python3.10 | python3.11"
|
||||
- "python3.9-venv | python3.10-venv | python3.11-venv"
|
||||
- "python3.9-dev | python3.10-dev | python3.11-dev"
|
||||
- python3-pip
|
||||
- python3-cffi
|
||||
- python3-brotli
|
||||
@@ -36,4 +36,3 @@ dependencies:
|
||||
targets:
|
||||
ubuntu-20.04: true
|
||||
debian-11: true
|
||||
debian-12: true
|
||||
|
@@ -5,7 +5,9 @@ exclude: |
|
||||
InvenTree/InvenTree/static/.*|
|
||||
InvenTree/locale/.*|
|
||||
src/frontend/src/locales/.*|
|
||||
.*/migrations/.*
|
||||
.*/migrations/.* |
|
||||
src/frontend/yarn.lock |
|
||||
yarn.lock
|
||||
)$
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
@@ -16,7 +18,7 @@ repos:
|
||||
- id: check-yaml
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.1.11
|
||||
rev: v0.3.4
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
args: [--preview]
|
||||
@@ -25,19 +27,19 @@ repos:
|
||||
--fix,
|
||||
--preview
|
||||
]
|
||||
- repo: https://github.com/jazzband/pip-tools
|
||||
rev: 7.3.0
|
||||
- repo: https://github.com/matmair/ruff-pre-commit
|
||||
rev: fac27ee349cbf0f0d71c1069854bfe371d1c62a1 # uv-0.1.23
|
||||
hooks:
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements-dev.in
|
||||
args: [requirements-dev.in, -o, requirements-dev.txt]
|
||||
args: [requirements-dev.in, -o, requirements-dev.txt, --python-version=3.9, --no-strip-extras]
|
||||
files: ^requirements-dev\.(in|txt)$
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements.txt
|
||||
args: [requirements.in, -o, requirements.txt]
|
||||
args: [requirements.in, -o, requirements.txt,--python-version=3.9, --no-strip-extras]
|
||||
files: ^requirements\.(in|txt)$
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.34.0
|
||||
rev: v1.34.1
|
||||
hooks:
|
||||
- id: djlint-django
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
@@ -52,7 +54,7 @@ repos:
|
||||
src/frontend/src/locales/.* |
|
||||
)$
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: "v3.0.3"
|
||||
rev: "v4.0.0-alpha.8"
|
||||
hooks:
|
||||
- id: prettier
|
||||
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
|
||||
@@ -60,7 +62,7 @@ repos:
|
||||
- "prettier@^2.4.1"
|
||||
- "@trivago/prettier-plugin-sort-imports"
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: "v8.51.0"
|
||||
rev: "v9.0.0-beta.2"
|
||||
hooks:
|
||||
- id: eslint
|
||||
additional_dependencies:
|
||||
@@ -71,3 +73,11 @@ repos:
|
||||
- "@typescript-eslint/eslint-plugin@latest"
|
||||
- "@typescript-eslint/parser"
|
||||
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.18.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
#- repo: https://github.com/jumanjihouse/pre-commit-hooks
|
||||
# rev: 3.0.0
|
||||
# hooks:
|
||||
# - id: shellcheck
|
||||
|
9
.vscode/launch.json
vendored
9
.vscode/launch.json
vendored
@@ -14,13 +14,20 @@
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "Python: Django - 3rd party",
|
||||
"name": "InvenTree Server - 3rd party",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/InvenTree/manage.py",
|
||||
"args": ["runserver"],
|
||||
"django": true,
|
||||
"justMyCode": false
|
||||
},
|
||||
{
|
||||
"name": "InvenTree Frontend - Vite",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:5173",
|
||||
"webRoot": "${workspaceFolder}/src/frontend"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
2
.vscode/tasks.json
vendored
2
.vscode/tasks.json
vendored
@@ -45,7 +45,7 @@
|
||||
{
|
||||
"label": "setup-test",
|
||||
"type": "shell",
|
||||
"command": "inv setup-test --path dev/inventree-demo-dataset",
|
||||
"command": "inv setup-test -i --path dev/inventree-demo-dataset",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
|
261
CONTRIBUTING.md
261
CONTRIBUTING.md
@@ -1,259 +1,6 @@
|
||||
### Contributing to InvenTree
|
||||
|
||||
Hi there, thank you for your interest in contributing!
|
||||
Please read the contribution guidelines below, before submitting your first pull request to the InvenTree codebase.
|
||||
Please read our contribution guidelines, before submitting your first pull request to the InvenTree codebase.
|
||||
|
||||
## Quickstart
|
||||
|
||||
The following commands will get you quickly configure and run a development server, complete with a demo dataset to work with:
|
||||
|
||||
### Bare Metal
|
||||
|
||||
```bash
|
||||
git clone https://github.com/inventree/InvenTree.git && cd InvenTree
|
||||
python3 -m venv env && source env/bin/activate
|
||||
pip install invoke && invoke
|
||||
pip install invoke && invoke setup-dev --tests
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
git clone https://github.com/inventree/InvenTree.git && cd InvenTree
|
||||
docker compose run inventree-dev-server invoke install
|
||||
docker compose run inventree-dev-server invoke setup-test --dev
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Read the [InvenTree setup documentation](https://docs.inventree.org/en/latest/start/intro/) for a complete installation reference guide.
|
||||
|
||||
### Setup Devtools
|
||||
|
||||
Run the following command to set up all toolsets for development.
|
||||
|
||||
```bash
|
||||
invoke setup-dev
|
||||
```
|
||||
|
||||
*We recommend you run this command before starting to contribute. This will install and set up `pre-commit` to run some checks before each commit and help reduce errors.*
|
||||
|
||||
## 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.
|
||||
|
||||
### Version Numbering
|
||||
|
||||
InvenTree version numbering follows the [semantic versioning](https://semver.org/) specification.
|
||||
|
||||
### Master Branch
|
||||
|
||||
The HEAD of the "main" or "master" branch of InvenTree represents the current "latest" state of code development.
|
||||
|
||||
- All feature branches are merged into master
|
||||
- All bug fixes are merged into master
|
||||
|
||||
**No pushing to master:** New features must be submitted as a pull request from a separate branch (one branch per feature).
|
||||
|
||||
### Feature Branches
|
||||
|
||||
Feature branches should be branched *from* the *master* branch.
|
||||
|
||||
- One major feature per branch / pull request
|
||||
- Feature pull requests are merged back *into* the master branch
|
||||
- Features *may* also be merged into a release candidate branch
|
||||
|
||||
### Stable Branch
|
||||
|
||||
The HEAD of the "stable" branch represents the latest stable release code.
|
||||
|
||||
- Versioned releases are merged into the "stable" branch
|
||||
- Bug fix branches are made *from* the "stable" branch
|
||||
|
||||
#### Release Candidate Branches
|
||||
|
||||
- Release candidate branches are made from master, and merged into stable.
|
||||
- RC branches are targeted at a major/minor version e.g. "0.5"
|
||||
- When a release candidate branch is merged into *stable*, the release is tagged
|
||||
|
||||
#### Bugfix Branches
|
||||
|
||||
- If a bug is discovered in a tagged release version of InvenTree, a "bugfix" or "hotfix" branch should be made *from* that tagged release
|
||||
- When approved, the branch is merged back *into* stable, with an incremented PATCH number (e.g. 0.4.1 -> 0.4.2)
|
||||
- The bugfix *must* also be cherry picked into the *master* branch.
|
||||
|
||||
## Environment
|
||||
### Target version
|
||||
We are currently targeting:
|
||||
| Name | Minimum version | Note |
|
||||
|---|---| --- |
|
||||
| Python | 3.9 | |
|
||||
| Django | 3.2 | |
|
||||
| Node | 18 | Only needed for frontend development |
|
||||
|
||||
### Auto creating updates
|
||||
The following tools can be used to auto-upgrade syntax that was depreciated in new versions:
|
||||
```bash
|
||||
pip install pyupgrade
|
||||
pip install django-upgrade
|
||||
```
|
||||
|
||||
To update the codebase run the following script.
|
||||
```bash
|
||||
pyupgrade `find . -name "*.py"`
|
||||
django-upgrade --target-version 3.2 `find . -name "*.py"`
|
||||
```
|
||||
|
||||
## Credits
|
||||
If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree/blob/master/docs/docs/credits.md). Please try to do that as timely as possible.
|
||||
|
||||
|
||||
## Migration Files
|
||||
|
||||
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate` and commit the migration files before submitting the PR.
|
||||
|
||||
*Note: A github action checks for unstaged migration files and will reject the PR if it finds any!*
|
||||
|
||||
## Unit Testing
|
||||
|
||||
Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage for any new features is insufficient, or the overall code coverage is decreased.
|
||||
|
||||
The InvenTree code base makes use of [GitHub actions](https://github.com/features/actions) to run a suite of automated tests against the code base every time a new pull request is received. These actions include (but are not limited to):
|
||||
|
||||
- Checking Python and Javascript code against standard style guides
|
||||
- Running unit test suite
|
||||
- Automated building and pushing of docker images
|
||||
- Generating translation files
|
||||
|
||||
The various github actions can be found in the `./github/workflows` directory
|
||||
|
||||
### Run tests locally
|
||||
|
||||
To run test locally, use:
|
||||
```
|
||||
invoke test
|
||||
```
|
||||
|
||||
To run only partial tests, for example for a module use:
|
||||
```
|
||||
invoke test --runtest order
|
||||
```
|
||||
|
||||
To see all the available options:
|
||||
|
||||
```
|
||||
invoke test --help
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
Code style is automatically checked as part of the project's CI pipeline on GitHub. This means that any pull requests which do not conform to the style guidelines will fail CI checks.
|
||||
|
||||
### Backend Code
|
||||
|
||||
Backend code (Python) is checked against the [PEP style guidelines](https://peps.python.org/pep-0008/). Please write docstrings for each function and class - we follow the [google doc-style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for python.
|
||||
|
||||
### Frontend Code
|
||||
|
||||
Frontend code (Javascript) is checked using [eslint](https://eslint.org/). While docstrings are not enforced for front-end code, good code documentation is encouraged!
|
||||
|
||||
### Running Checks Locally
|
||||
|
||||
If you have followed the setup devtools procedure, then code style checking is performend automatically whenever you commit changes to the code.
|
||||
|
||||
### Django templates
|
||||
|
||||
Django are checked by [djlint](https://github.com/Riverside-Healthcare/djlint) through pre-commit.
|
||||
|
||||
The following rules out of the [default set](https://djlint.com/docs/linter/) are not applied:
|
||||
```bash
|
||||
D018: (Django) Internal links should use the { % url ... % } pattern
|
||||
H006: Img tag should have height and width attributes
|
||||
H008: Attributes should be double quoted
|
||||
H021: Inline styles should be avoided
|
||||
H023: Do not use entity references
|
||||
H025: Tag seems to be an orphan
|
||||
H030: Consider adding a meta description
|
||||
H031: Consider adding meta keywords
|
||||
T002: Double quotes should be used in tags
|
||||
```
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
New features or updates to existing features should be accompanied by user documentation.
|
||||
|
||||
## Translations
|
||||
|
||||
Any user-facing strings *must* be passed through the translation engine.
|
||||
|
||||
- InvenTree code is written in English
|
||||
- User translatable strings are provided in English as the primary language
|
||||
- Secondary language translations are provided [via Crowdin](https://crowdin.com/project/inventree)
|
||||
|
||||
*Note: Translation files are updated via GitHub actions - you do not need to compile translations files before submitting a pull request!*
|
||||
|
||||
### Python Code
|
||||
|
||||
For strings exposed via Python code, use the following format:
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
user_facing_string = _('This string will be exposed to the translation engine!')
|
||||
```
|
||||
|
||||
### Templated Strings
|
||||
|
||||
HTML and javascript files are passed through the django templating engine. Translatable strings are implemented as follows:
|
||||
|
||||
```html
|
||||
{ % load i18n % }
|
||||
|
||||
<span>{ % trans "This string will be translated" % } - this string will not!</span>
|
||||
```
|
||||
|
||||
## Github use
|
||||
|
||||
### Tags
|
||||
|
||||
The tags describe issues and PRs in multiple areas:
|
||||
|
||||
| Area | Name | Description |
|
||||
| --- | --- | --- |
|
||||
| Triage Labels | | |
|
||||
| | triage:not-checked | Item was not checked by the core team |
|
||||
| | triage:not-approved | Item is not green-light by maintainer |
|
||||
| Type Labels | | |
|
||||
| | breaking | Indicates a major update or change which breaks compatibility |
|
||||
| | bug | Identifies a bug which needs to be addressed |
|
||||
| | dependency | Relates to a project dependency |
|
||||
| | duplicate | Duplicate of another issue or PR |
|
||||
| | enhancement | This is an suggested enhancement, extending the functionality of an existing feature |
|
||||
| | experimental | This is a new *experimental* feature which needs to be enabled manually |
|
||||
| | feature | This is a new feature, introducing novel functionality |
|
||||
| | help wanted | Assistance required |
|
||||
| | invalid | This issue or PR is considered invalid |
|
||||
| | inactive | Indicates lack of activity |
|
||||
| | migration | Database migration, requires special attention |
|
||||
| | question | This is a question |
|
||||
| | roadmap | This is a roadmap feature with no immediate plans for implementation |
|
||||
| | security | Relates to a security issue |
|
||||
| | starter | Good issue for a developer new to the project |
|
||||
| | wontfix | No work will be done against this issue or PR |
|
||||
| Feature Labels | | |
|
||||
| | API | Relates to the API |
|
||||
| | barcode | Barcode scanning and integration |
|
||||
| | build | Build orders |
|
||||
| | importer | Data importing and processing |
|
||||
| | order | Purchase order and sales orders |
|
||||
| | part | Parts |
|
||||
| | plugin | Plugin ecosystem |
|
||||
| | pricing | Pricing functionality |
|
||||
| | report | Report generation |
|
||||
| | stock | Stock item management |
|
||||
| | user interface | User interface |
|
||||
| Ecosystem Labels | | |
|
||||
| | backport | Tags that the issue will be backported to a stable branch as a bug-fix |
|
||||
| | demo | Relates to the InvenTree demo server or dataset |
|
||||
| | docker | Docker / docker-compose |
|
||||
| | CI | CI / unit testing ecosystem |
|
||||
| | refactor | Refactoring existing code |
|
||||
| | setup | Relates to the InvenTree setup / installation process |
|
||||
Refer to our [contribution guidelines](https://docs.inventree.org/en/latest/develop/contributing/) for more information!
|
||||
|
33
Dockerfile
33
Dockerfile
@@ -9,25 +9,26 @@
|
||||
# - Runs InvenTree web server under django development server
|
||||
# - Monitors source files for any changes, and live-reloads server
|
||||
|
||||
ARG base_image=python:3.10-alpine3.18
|
||||
ARG base_image=python:3.11-alpine3.18
|
||||
FROM ${base_image} as inventree_base
|
||||
|
||||
# Build arguments for this image
|
||||
ARG commit_tag=""
|
||||
ARG commit_hash=""
|
||||
ARG commit_date=""
|
||||
ARG commit_tag=""
|
||||
|
||||
ARG data_dir="data"
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
|
||||
ENV INVOKE_RUN_SHELL="/bin/ash"
|
||||
|
||||
ENV INVENTREE_LOG_LEVEL="WARNING"
|
||||
ENV INVENTREE_DOCKER="true"
|
||||
|
||||
# InvenTree paths
|
||||
ENV INVENTREE_HOME="/home/inventree"
|
||||
ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
|
||||
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
|
||||
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/${data_dir}"
|
||||
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
||||
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
||||
ENV INVENTREE_BACKUP_DIR="${INVENTREE_DATA_DIR}/backup"
|
||||
@@ -61,12 +62,11 @@ RUN apk add --no-cache \
|
||||
libjpeg libwebp zlib \
|
||||
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#alpine-3-12
|
||||
py3-pip py3-pillow py3-cffi py3-brotli pango poppler-utils openldap \
|
||||
# SQLite support
|
||||
sqlite \
|
||||
# PostgreSQL support
|
||||
postgresql-libs postgresql-client \
|
||||
# MySQL / MariaDB support
|
||||
mariadb-connector-c-dev mariadb-client && \
|
||||
# Postgres client
|
||||
postgresql13-client \
|
||||
# MySQL / MariaDB client
|
||||
mariadb-client mariadb-connector-c \
|
||||
&& \
|
||||
# fonts
|
||||
apk --update --upgrade --no-cache add fontconfig ttf-freefont font-noto terminus-font && fc-cache -f
|
||||
|
||||
@@ -90,13 +90,13 @@ RUN if [ `apk --print-arch` = "armv7" ]; then \
|
||||
COPY tasks.py docker/gunicorn.conf.py docker/init.sh ./
|
||||
RUN chmod +x init.sh
|
||||
|
||||
ENTRYPOINT ["/bin/sh", "./init.sh"]
|
||||
ENTRYPOINT ["/bin/ash", "./init.sh"]
|
||||
|
||||
FROM inventree_base as prebuild
|
||||
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
RUN ./install_build_packages.sh --no-cache --virtual .build-deps && \
|
||||
pip install --user -r base_requirements.txt -r requirements.txt --no-cache-dir && \
|
||||
pip install --user -r base_requirements.txt -r requirements.txt --no-cache && \
|
||||
apk --purge del .build-deps
|
||||
|
||||
# Frontend builder image:
|
||||
@@ -141,7 +141,7 @@ EXPOSE 5173
|
||||
# Install packages required for building python packages
|
||||
RUN ./install_build_packages.sh
|
||||
|
||||
RUN pip install -r base_requirements.txt --no-cache-dir
|
||||
RUN pip install uv --no-cache-dir && pip install -r base_requirements.txt --no-cache
|
||||
|
||||
# Install nodejs / npm / yarn
|
||||
|
||||
@@ -164,10 +164,3 @@ ENTRYPOINT ["/bin/ash", "./docker/init.sh"]
|
||||
|
||||
# Launch the development server
|
||||
CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"]
|
||||
|
||||
# Image target for devcontainer
|
||||
FROM dev as devcontainer
|
||||
|
||||
ARG workspace="/workspaces/InvenTree"
|
||||
|
||||
WORKDIR ${WORKSPACE}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
"""Main JSON interface views."""
|
||||
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
@@ -8,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_q.models import OrmQ
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
@@ -18,6 +21,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import ListCreateAPI
|
||||
from InvenTree.permissions import RolePermission
|
||||
from InvenTree.templatetags.inventree_extras import plugins_info
|
||||
from part.models import Part
|
||||
from plugin.serializers import MetadataSerializer
|
||||
from users.models import ApiToken
|
||||
|
||||
@@ -28,11 +32,41 @@ from .version import inventreeApiText
|
||||
from .views import AjaxView
|
||||
|
||||
|
||||
class VersionViewSerializer(serializers.Serializer):
|
||||
"""Serializer for a single version."""
|
||||
|
||||
class VersionSerializer(serializers.Serializer):
|
||||
"""Serializer for server version."""
|
||||
|
||||
server = serializers.CharField()
|
||||
api = serializers.IntegerField()
|
||||
commit_hash = serializers.CharField()
|
||||
commit_date = serializers.CharField()
|
||||
commit_branch = serializers.CharField()
|
||||
python = serializers.CharField()
|
||||
django = serializers.CharField()
|
||||
|
||||
class LinkSerializer(serializers.Serializer):
|
||||
"""Serializer for all possible links."""
|
||||
|
||||
doc = serializers.URLField()
|
||||
code = serializers.URLField()
|
||||
credit = serializers.URLField()
|
||||
app = serializers.URLField()
|
||||
bug = serializers.URLField()
|
||||
|
||||
dev = serializers.BooleanField()
|
||||
up_to_date = serializers.BooleanField()
|
||||
version = VersionSerializer()
|
||||
links = LinkSerializer()
|
||||
|
||||
|
||||
class VersionView(APIView):
|
||||
"""Simple JSON endpoint for InvenTree version information."""
|
||||
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
@extend_schema(responses={200: OpenApiResponse(response=VersionViewSerializer)})
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Return information about the InvenTree server."""
|
||||
return JsonResponse({
|
||||
@@ -81,6 +115,8 @@ class VersionApiSerializer(serializers.Serializer):
|
||||
class VersionTextView(ListAPI):
|
||||
"""Simple JSON endpoint for InvenTree version text."""
|
||||
|
||||
serializer_class = VersionSerializer
|
||||
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
@extend_schema(responses={200: OpenApiResponse(response=VersionApiSerializer)})
|
||||
@@ -116,40 +152,37 @@ class InfoView(AjaxView):
|
||||
'worker_running': is_worker_running(),
|
||||
'worker_pending_tasks': self.worker_pending_tasks(),
|
||||
'plugins_enabled': settings.PLUGINS_ENABLED,
|
||||
'plugins_install_disabled': settings.PLUGINS_INSTALL_DISABLED,
|
||||
'active_plugins': plugins_info(),
|
||||
'email_configured': is_email_configured(),
|
||||
'debug_mode': settings.DEBUG,
|
||||
'docker_mode': settings.DOCKER,
|
||||
'default_locale': settings.LANGUAGE_CODE,
|
||||
# Following fields are only available to staff users
|
||||
'system_health': check_system_health() if is_staff else None,
|
||||
'database': InvenTree.version.inventreeDatabase() if is_staff else None,
|
||||
'platform': InvenTree.version.inventreePlatform() if is_staff else None,
|
||||
'installer': InvenTree.version.inventreeInstaller() if is_staff else None,
|
||||
'target': InvenTree.version.inventreeTarget() if is_staff else None,
|
||||
'default_locale': settings.LANGUAGE_CODE,
|
||||
}
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
def check_auth_header(self, request):
|
||||
"""Check if user is authenticated via a token in the header."""
|
||||
# TODO @matmair: remove after refacgtor of Token check is done
|
||||
headers = request.headers.get(
|
||||
'Authorization', request.headers.get('authorization')
|
||||
)
|
||||
if not headers:
|
||||
return False
|
||||
from InvenTree.middleware import get_token_from_request
|
||||
|
||||
auth = headers.strip()
|
||||
if not (auth.lower().startswith('token') and len(auth.split()) == 2):
|
||||
return False
|
||||
if token := get_token_from_request(request):
|
||||
# Does the provided token match a valid user?
|
||||
try:
|
||||
token = ApiToken.objects.get(key=token)
|
||||
|
||||
# Check if the token is active and the user is a staff member
|
||||
if token.active and token.user and token.user.is_staff:
|
||||
return True
|
||||
except ApiToken.DoesNotExist:
|
||||
pass
|
||||
|
||||
token_key = auth.split()[1]
|
||||
try:
|
||||
token = ApiToken.objects.get(key=token_key)
|
||||
if token.active and token.user and token.user.is_staff:
|
||||
return True
|
||||
except ApiToken.DoesNotExist:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@@ -328,7 +361,17 @@ class AttachmentMixin:
|
||||
attachment.save()
|
||||
|
||||
|
||||
class APISearchView(APIView):
|
||||
class APISearchViewSerializer(serializers.Serializer):
|
||||
"""Serializer for the APISearchView."""
|
||||
|
||||
search = serializers.CharField()
|
||||
search_regex = serializers.BooleanField(default=False, required=False)
|
||||
search_whole = serializers.BooleanField(default=False, required=False)
|
||||
limit = serializers.IntegerField(default=1, required=False)
|
||||
offset = serializers.IntegerField(default=0, required=False)
|
||||
|
||||
|
||||
class APISearchView(GenericAPIView):
|
||||
"""A general-purpose 'search' API endpoint.
|
||||
|
||||
Returns hits against a number of different models simultaneously,
|
||||
@@ -338,6 +381,7 @@ class APISearchView(APIView):
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = APISearchViewSerializer
|
||||
|
||||
def get_result_types(self):
|
||||
"""Construct a list of search types we can return."""
|
||||
@@ -450,4 +494,7 @@ class MetadataView(RetrieveUpdateAPI):
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance."""
|
||||
# Detect if we are currently generating the OpenAPI schema
|
||||
if 'spectacular' in sys.argv:
|
||||
return MetadataSerializer(Part, *args, **kwargs)
|
||||
return MetadataSerializer(self.get_model_type(), *args, **kwargs)
|
||||
|
@@ -1,11 +1,102 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 162
|
||||
INVENTREE_API_VERSION = 184
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v184 - 2024-03-17 : https://github.com/inventree/InvenTree/pull/10464
|
||||
- Add additional fields for tests (start/end datetime, test station)
|
||||
|
||||
v183 - 2024-03-14 : https://github.com/inventree/InvenTree/pull/5972
|
||||
- Adds "category_default_location" annotated field to part serializer
|
||||
- Adds "part_detail.category_default_location" annotated field to stock item serializer
|
||||
- Adds "part_detail.category_default_location" annotated field to purchase order line serializer
|
||||
- Adds "parent_default_location" annotated field to category serializer
|
||||
|
||||
v182 - 2024-03-13 : https://github.com/inventree/InvenTree/pull/6714
|
||||
- Expose ReportSnippet model to the /report/snippet/ API endpoint
|
||||
- Expose ReportAsset model to the /report/asset/ API endpoint
|
||||
|
||||
v181 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6541
|
||||
- Adds "width" and "height" fields to the LabelTemplate API endpoint
|
||||
- Adds "page_size" and "landscape" fields to the ReportTemplate API endpoint
|
||||
|
||||
v180 - 2024-3-02 : https://github.com/inventree/InvenTree/pull/6463
|
||||
- Tweaks to API documentation to allow automatic documentation generation
|
||||
|
||||
v179 - 2024-03-01 : https://github.com/inventree/InvenTree/pull/6605
|
||||
- Adds "subcategories" count to PartCategory serializer
|
||||
- Adds "sublocations" count to StockLocation serializer
|
||||
- Adds "image" field to PartBrief serializer
|
||||
- Adds "image" field to CompanyBrief serializer
|
||||
|
||||
v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604
|
||||
- Adds "external_stock" field to the Part API endpoint
|
||||
- Adds "external_stock" field to the BomItem API endpoint
|
||||
- Adds "external_stock" field to the BuildLine API endpoint
|
||||
- Stock quantites represented in the BuildLine API endpoint are now filtered by Build.source_location
|
||||
|
||||
v177 - 2024-02-27 : https://github.com/inventree/InvenTree/pull/6581
|
||||
- Adds "subcategoies" count to PartCategoryTree serializer
|
||||
- Adds "sublocations" count to StockLocationTree serializer
|
||||
|
||||
v176 - 2024-02-26 : https://github.com/inventree/InvenTree/pull/6535
|
||||
- Adds the field "plugins_install_disabled" to the Server info API endpoint
|
||||
|
||||
v175 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6538
|
||||
- Adds "parts" count to PartParameterTemplate serializer
|
||||
|
||||
v174 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6536
|
||||
- Expose PartCategory filters to the API documentation
|
||||
- Expose StockLocation filters to the API documentation
|
||||
|
||||
v173 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6483
|
||||
- Adds "merge_items" to the PurchaseOrderLine create API endpoint
|
||||
- Adds "auto_pricing" to the PurchaseOrderLine create/update API endpoint
|
||||
|
||||
v172 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6526
|
||||
- Adds "enabled" field to the PartTestTemplate API endpoint
|
||||
- Adds "enabled" filter to the PartTestTemplate list
|
||||
- Adds "enabled" filter to the StockItemTestResult list
|
||||
|
||||
v171 - 2024-02-19 : https://github.com/inventree/InvenTree/pull/6516
|
||||
- Adds "key" as a filterable parameter to PartTestTemplate list endpoint
|
||||
|
||||
v170 -> 2024-02-19 : https://github.com/inventree/InvenTree/pull/6514
|
||||
- Adds "has_results" filter to the PartTestTemplate list endpoint
|
||||
|
||||
v169 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/6430
|
||||
- Adds 'key' field to PartTestTemplate API endpoint
|
||||
- Adds annotated 'results' field to PartTestTemplate API endpoint
|
||||
- Adds 'template' field to StockItemTestResult API endpoint
|
||||
|
||||
v168 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/4824
|
||||
- Adds machine CRUD API endpoints
|
||||
- Adds machine settings API endpoints
|
||||
- Adds machine restart API endpoint
|
||||
- Adds machine types/drivers list API endpoints
|
||||
- Adds machine registry status API endpoint
|
||||
- Adds 'required' field to the global Settings API
|
||||
- Discover sub-sub classes of the StatusCode API
|
||||
|
||||
v167 -> 2024-02-07: https://github.com/inventree/InvenTree/pull/6440
|
||||
- Fixes for OpenAPI schema generation
|
||||
|
||||
v166 -> 2024-02-04 : https://github.com/inventree/InvenTree/pull/6400
|
||||
- Adds package_name to plugin API
|
||||
- Adds mechanism for uninstalling plugins via the API
|
||||
|
||||
v165 -> 2024-01-28 : https://github.com/inventree/InvenTree/pull/6040
|
||||
- Adds supplier_part.name, part.creation_user, part.required_for_sales_order
|
||||
|
||||
v164 -> 2024-01-24 : https://github.com/inventree/InvenTree/pull/6343
|
||||
- Adds "building" quantity to BuildLine API serializer
|
||||
|
||||
v163 -> 2024-01-22 : https://github.com/inventree/InvenTree/pull/6314
|
||||
- Extends API endpoint to expose auth configuration information for signin pages
|
||||
|
||||
v162 -> 2024-01-14 : https://github.com/inventree/InvenTree/pull/6230
|
||||
- Adds API endpoints to provide information on background tasks
|
||||
|
||||
|
@@ -58,6 +58,7 @@ class InvenTreeConfig(AppConfig):
|
||||
# Let the background worker check for migrations
|
||||
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations)
|
||||
|
||||
self.update_site_url()
|
||||
self.collect_notification_methods()
|
||||
self.collect_state_transition_methods()
|
||||
|
||||
@@ -223,6 +224,46 @@ class InvenTreeConfig(AppConfig):
|
||||
except Exception as e:
|
||||
logger.exception('Error updating exchange rates: %s (%s)', e, type(e))
|
||||
|
||||
def update_site_url(self):
|
||||
"""Update the site URL setting.
|
||||
|
||||
- If a fixed SITE_URL is specified (via configuration), it should override the INVENTREE_BASE_URL setting
|
||||
- If multi-site support is enabled, update the site URL for the current site
|
||||
"""
|
||||
import common.models
|
||||
|
||||
if not InvenTree.ready.canAppAccessDatabase():
|
||||
return
|
||||
|
||||
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
|
||||
return
|
||||
|
||||
if settings.SITE_URL:
|
||||
try:
|
||||
if (
|
||||
common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
|
||||
!= settings.SITE_URL
|
||||
):
|
||||
common.models.InvenTreeSetting.set_setting(
|
||||
'INVENTREE_BASE_URL', settings.SITE_URL
|
||||
)
|
||||
logger.info('Updated INVENTREE_SITE_URL to %s', settings.SITE_URL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If multi-site support is enabled, update the site URL for the current site
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
site = Site.objects.get_current()
|
||||
site.domain = settings.SITE_URL
|
||||
site.save()
|
||||
|
||||
logger.info('Updated current site URL to %s', settings.SITE_URL)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add_user_on_startup(self):
|
||||
"""Add a user on startup."""
|
||||
# stop if checks were already created
|
||||
|
85
InvenTree/InvenTree/backends.py
Normal file
85
InvenTree/InvenTree/backends.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Custom backend implementations."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
from maintenance_mode.backends import AbstractStateBackend
|
||||
|
||||
import common.models
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class InvenTreeMaintenanceModeBackend(AbstractStateBackend):
|
||||
"""Custom backend for managing state of maintenance mode.
|
||||
|
||||
Stores a timestamp in the database to determine when maintenance mode will elapse.
|
||||
"""
|
||||
|
||||
SETTING_KEY = '_MAINTENANCE_MODE'
|
||||
|
||||
def get_value(self) -> bool:
|
||||
"""Get the current state of the maintenance mode.
|
||||
|
||||
Returns:
|
||||
bool: True if maintenance mode is active, False otherwise.
|
||||
"""
|
||||
try:
|
||||
setting = common.models.InvenTreeSetting.objects.get(key=self.SETTING_KEY)
|
||||
value = str(setting.value).strip()
|
||||
except common.models.InvenTreeSetting.DoesNotExist:
|
||||
# Database is accessible, but setting is not available - assume False
|
||||
return False
|
||||
except (IntegrityError, OperationalError, ProgrammingError):
|
||||
# Database is inaccessible - assume we are not in maintenance mode
|
||||
logger.debug('Failed to read maintenance mode state - assuming True')
|
||||
return True
|
||||
|
||||
# Extract timestamp from string
|
||||
try:
|
||||
# If the timestamp is in the past, we are now *out* of maintenance mode
|
||||
timestamp = datetime.datetime.fromisoformat(value)
|
||||
return timestamp > datetime.datetime.now()
|
||||
except ValueError:
|
||||
# If the value is not a valid timestamp, assume maintenance mode is not active
|
||||
return False
|
||||
|
||||
def set_value(self, value: bool, retries: int = 5, minutes: int = 5):
|
||||
"""Set the state of the maintenance mode.
|
||||
|
||||
Instead of simply writing "true" or "false" to the setting,
|
||||
we write a timestamp to the setting, which is used to determine
|
||||
when maintenance mode will elapse.
|
||||
This ensures that we will always *exit* maintenance mode after a certain time period.
|
||||
"""
|
||||
logger.debug('Setting maintenance mode state: %s', value)
|
||||
|
||||
if value:
|
||||
# Save as isoformat
|
||||
timestamp = datetime.datetime.now() + datetime.timedelta(minutes=minutes)
|
||||
timestamp = timestamp.isoformat()
|
||||
else:
|
||||
# Blank timestamp means maintenance mode is not active
|
||||
timestamp = ''
|
||||
|
||||
while retries > 0:
|
||||
try:
|
||||
common.models.InvenTreeSetting.set_setting(self.SETTING_KEY, timestamp)
|
||||
|
||||
# Read the value back to confirm
|
||||
if self.get_value() == value:
|
||||
break
|
||||
except (IntegrityError, OperationalError, ProgrammingError):
|
||||
# In the database is locked, then
|
||||
logger.debug(
|
||||
'Failed to set maintenance mode state (%s retries left)', retries
|
||||
)
|
||||
time.sleep(0.1)
|
||||
|
||||
retries -= 1
|
||||
|
||||
if retries == 0:
|
||||
logger.warning('Failed to set maintenance mode state')
|
@@ -10,6 +10,9 @@ import string
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import Storage
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
CONFIG_DATA = None
|
||||
CONFIG_LOOKUPS = {}
|
||||
@@ -69,11 +72,16 @@ def get_base_dir() -> Path:
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> None:
|
||||
def ensure_dir(path: Path, storage=None) -> None:
|
||||
"""Ensure that a directory exists.
|
||||
|
||||
If it does not exist, create it.
|
||||
"""
|
||||
if storage and isinstance(storage, Storage):
|
||||
if not storage.exists(str(path)):
|
||||
storage.save(str(path / '.empty'), ContentFile(''))
|
||||
return
|
||||
|
||||
if not path.exists():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
@@ -72,7 +72,7 @@ def user_roles(request):
|
||||
|
||||
roles = {}
|
||||
|
||||
for role in RuleSet.RULESET_MODELS.keys():
|
||||
for role in RuleSet.get_ruleset_models().keys():
|
||||
permissions = {}
|
||||
|
||||
for perm in ['view', 'add', 'change', 'delete']:
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Helper functions for converting between units."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -9,7 +10,6 @@ import pint
|
||||
|
||||
_unit_registry = None
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
@@ -36,7 +36,12 @@ def reload_unit_registry():
|
||||
|
||||
_unit_registry = None
|
||||
|
||||
reg = pint.UnitRegistry()
|
||||
reg = pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
|
||||
|
||||
# Aliases for temperature units
|
||||
reg.define('@alias degC = Celsius')
|
||||
reg.define('@alias degF = Fahrenheit')
|
||||
reg.define('@alias degK = Kelvin')
|
||||
|
||||
# Define some "standard" additional units
|
||||
reg.define('piece = 1')
|
||||
@@ -70,6 +75,64 @@ def reload_unit_registry():
|
||||
return reg
|
||||
|
||||
|
||||
def from_engineering_notation(value):
|
||||
"""Convert a provided value to 'natural' representation from 'engineering' notation.
|
||||
|
||||
Ref: https://en.wikipedia.org/wiki/Engineering_notation
|
||||
|
||||
In "engineering notation", the unit (or SI prefix) is often combined with the value,
|
||||
and replaces the decimal point.
|
||||
|
||||
Examples:
|
||||
- 1K2 -> 1.2K
|
||||
- 3n05 -> 3.05n
|
||||
- 8R6 -> 8.6R
|
||||
|
||||
And, we should also take into account any provided trailing strings:
|
||||
|
||||
- 1K2 ohm -> 1.2K ohm
|
||||
- 10n005F -> 10.005nF
|
||||
"""
|
||||
value = str(value).strip()
|
||||
|
||||
pattern = f'(\d+)([a-zA-Z]+)(\d+)(.*)'
|
||||
|
||||
if match := re.match(pattern, value):
|
||||
left, prefix, right, suffix = match.groups()
|
||||
return f'{left}.{right}{prefix}{suffix}'
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def convert_value(value, unit):
|
||||
"""Attempt to convert a value to a specified unit.
|
||||
|
||||
Arguments:
|
||||
value: The value to convert
|
||||
unit: The target unit to convert to
|
||||
|
||||
Returns:
|
||||
The converted value (ideally a pint.Quantity value)
|
||||
|
||||
Raises:
|
||||
Exception if the value cannot be converted to the specified unit
|
||||
"""
|
||||
ureg = get_unit_registry()
|
||||
|
||||
# Convert the provided value to a pint.Quantity object
|
||||
value = ureg.Quantity(value)
|
||||
|
||||
# Convert to the specified unit
|
||||
if unit:
|
||||
if is_dimensionless(value):
|
||||
magnitude = value.to_base_units().magnitude
|
||||
value = ureg.Quantity(magnitude, unit)
|
||||
else:
|
||||
value = value.to(unit)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def convert_physical_value(value: str, unit: str = None, strip_units=True):
|
||||
"""Validate that the provided value is a valid physical quantity.
|
||||
|
||||
@@ -84,44 +147,58 @@ def convert_physical_value(value: str, unit: str = None, strip_units=True):
|
||||
Returns:
|
||||
The converted quantity, in the specified units
|
||||
"""
|
||||
ureg = get_unit_registry()
|
||||
|
||||
# Check that the provided unit is available in the unit registry
|
||||
if unit:
|
||||
try:
|
||||
valid = unit in ureg
|
||||
except Exception as exc:
|
||||
valid = False
|
||||
|
||||
if not valid:
|
||||
raise ValidationError(_(f'Invalid unit provided ({unit})'))
|
||||
|
||||
original = str(value).strip()
|
||||
|
||||
# Ensure that the value is a string
|
||||
value = str(value).strip() if value else ''
|
||||
unit = str(unit).strip() if unit else ''
|
||||
|
||||
# Handle imperial length measurements
|
||||
if value.count("'") == 1 and value.endswith("'"):
|
||||
value = value.replace("'", ' feet')
|
||||
|
||||
if value.count('"') == 1 and value.endswith('"'):
|
||||
value = value.replace('"', ' inches')
|
||||
|
||||
# Error on blank values
|
||||
if not value:
|
||||
raise ValidationError(_('No value provided'))
|
||||
|
||||
# Create a "backup" value which be tried if the first value fails
|
||||
# e.g. value = "10k" and unit = "ohm" -> "10kohm"
|
||||
# e.g. value = "10m" and unit = "F" -> "10mF"
|
||||
# Construct a list of values to "attempt" to convert
|
||||
attempts = [value]
|
||||
|
||||
# Attempt to convert from engineering notation
|
||||
eng = from_engineering_notation(value)
|
||||
attempts.append(eng)
|
||||
|
||||
# Append the unit, if provided
|
||||
# These are the "final" attempts to convert the value, and *must* appear after previous attempts
|
||||
if unit:
|
||||
backup_value = value + unit
|
||||
else:
|
||||
backup_value = None
|
||||
attempts.append(f'{value}{unit}')
|
||||
attempts.append(f'{eng}{unit}')
|
||||
|
||||
ureg = get_unit_registry()
|
||||
value = None
|
||||
|
||||
try:
|
||||
value = ureg.Quantity(value)
|
||||
|
||||
if unit:
|
||||
if is_dimensionless(value):
|
||||
magnitude = value.to_base_units().magnitude
|
||||
value = ureg.Quantity(magnitude, unit)
|
||||
else:
|
||||
value = value.to(unit)
|
||||
|
||||
except Exception:
|
||||
if backup_value:
|
||||
try:
|
||||
value = ureg.Quantity(backup_value)
|
||||
except Exception:
|
||||
value = None
|
||||
else:
|
||||
# Run through the available "attempts", take the first successful result
|
||||
for attempt in attempts:
|
||||
try:
|
||||
value = convert_value(attempt, unit)
|
||||
break
|
||||
except Exception as exc:
|
||||
value = None
|
||||
pass
|
||||
|
||||
if value is None:
|
||||
if unit:
|
||||
|
@@ -53,7 +53,10 @@ def log_error(path, error_name=None, error_info=None, error_data=None):
|
||||
if error_data:
|
||||
data = error_data
|
||||
else:
|
||||
data = '\n'.join(traceback.format_exception(kind, info, data))
|
||||
try:
|
||||
data = '\n'.join(traceback.format_exception(kind, info, data))
|
||||
except AttributeError:
|
||||
data = 'No traceback information available'
|
||||
|
||||
# Log error to stderr
|
||||
logger.error(info)
|
||||
|
@@ -180,7 +180,12 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str:
|
||||
return result.group(name)
|
||||
|
||||
|
||||
def format_money(money: Money, decimal_places: int = None, format: str = None) -> str:
|
||||
def format_money(
|
||||
money: Money,
|
||||
decimal_places: int = None,
|
||||
format: str = None,
|
||||
include_symbol: bool = True,
|
||||
) -> str:
|
||||
"""Format money object according to the currently set local.
|
||||
|
||||
Args:
|
||||
@@ -203,10 +208,12 @@ def format_money(money: Money, decimal_places: int = None, format: str = None) -
|
||||
if decimal_places is not None:
|
||||
pattern.frac_prec = (decimal_places, decimal_places)
|
||||
|
||||
return pattern.apply(
|
||||
result = pattern.apply(
|
||||
money.amount,
|
||||
locale,
|
||||
currency=money.currency.code,
|
||||
currency=money.currency.code if include_symbol else '',
|
||||
currency_digits=decimal_places is None,
|
||||
decimal_quantization=decimal_places is not None,
|
||||
)
|
||||
|
||||
return result
|
||||
|
@@ -5,7 +5,6 @@ import logging
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.sites.models import Site
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -19,6 +18,7 @@ from crispy_forms.layout import Field, Layout
|
||||
from dj_rest_auth.registration.serializers import RegisterSerializer
|
||||
from rest_framework import serializers
|
||||
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.sso
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.exceptions import log_error
|
||||
@@ -289,7 +289,8 @@ class CustomUrlMixin:
|
||||
def get_email_confirmation_url(self, request, emailconfirmation):
|
||||
"""Custom email confirmation (activation) url."""
|
||||
url = reverse('account_confirm_email', args=[emailconfirmation.key])
|
||||
return Site.objects.get_current().domain + url
|
||||
|
||||
return InvenTree.helpers_model.construct_absolute_url(url)
|
||||
|
||||
|
||||
class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultAccountAdapter):
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Provides helper functions used throughout the InvenTree project."""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
@@ -8,8 +9,10 @@ import os
|
||||
import os.path
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import TypeVar
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
import django.utils.timezone as timezone
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
@@ -17,6 +20,7 @@ from django.core.files.storage import default_storage
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pytz
|
||||
import regex
|
||||
from bleach import clean
|
||||
from djmoney.money import Money
|
||||
@@ -30,16 +34,81 @@ from .settings import MEDIA_URL, STATIC_URL
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def generateTestKey(test_name):
|
||||
def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
|
||||
"""Extract an integer out of reference."""
|
||||
# Default value if we cannot convert to an integer
|
||||
ref_int = 0
|
||||
|
||||
reference = str(reference).strip()
|
||||
|
||||
# Ignore empty string
|
||||
if len(reference) == 0:
|
||||
return 0
|
||||
|
||||
# Look at the start of the string - can it be "integerized"?
|
||||
result = re.match(r'^(\d+)', reference)
|
||||
|
||||
if result and len(result.groups()) == 1:
|
||||
ref = result.groups()[0]
|
||||
try:
|
||||
ref_int = int(ref)
|
||||
except Exception:
|
||||
ref_int = 0
|
||||
else:
|
||||
# Look at the "end" of the string
|
||||
result = re.search(r'(\d+)$', reference)
|
||||
|
||||
if result and len(result.groups()) == 1:
|
||||
ref = result.groups()[0]
|
||||
try:
|
||||
ref_int = int(ref)
|
||||
except Exception:
|
||||
ref_int = 0
|
||||
|
||||
# Ensure that the returned values are within the range that can be stored in an IntegerField
|
||||
# Note: This will result in large values being "clipped"
|
||||
if clip is not None:
|
||||
if ref_int > clip:
|
||||
ref_int = clip
|
||||
elif ref_int < -clip:
|
||||
ref_int = -clip
|
||||
|
||||
if not allow_negative and ref_int < 0:
|
||||
ref_int = abs(ref_int)
|
||||
|
||||
return ref_int
|
||||
|
||||
|
||||
def generateTestKey(test_name: str) -> str:
|
||||
"""Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template.
|
||||
|
||||
Tests must be named such that they will have unique keys.
|
||||
"""
|
||||
if test_name is None:
|
||||
test_name = ''
|
||||
|
||||
key = test_name.strip().lower()
|
||||
key = key.replace(' ', '')
|
||||
|
||||
def valid_char(char: str):
|
||||
"""Determine if a particular character is valid for use in a test key."""
|
||||
if not char.isprintable():
|
||||
return False
|
||||
|
||||
if char.isidentifier():
|
||||
return True
|
||||
|
||||
if char.isalnum():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# Remove any characters that cannot be used to represent a variable
|
||||
key = re.sub(r'[^a-zA-Z0-9]', '', key)
|
||||
key = ''.join([c for c in key if valid_char(c)])
|
||||
|
||||
# If the key starts with a non-identifier character, prefix with an underscore
|
||||
if len(key) > 0 and not key[0].isidentifier():
|
||||
key = '_' + key
|
||||
|
||||
return key
|
||||
|
||||
@@ -797,6 +866,56 @@ def hash_file(filename: str):
|
||||
return hashlib.md5(open(filename, 'rb').read()).hexdigest()
|
||||
|
||||
|
||||
def server_timezone() -> str:
|
||||
"""Return the timezone of the server as a string.
|
||||
|
||||
e.g. "UTC" / "Australia/Sydney" etc
|
||||
"""
|
||||
return settings.TIME_ZONE
|
||||
|
||||
|
||||
def to_local_time(time, target_tz: str = None):
|
||||
"""Convert the provided time object to the local timezone.
|
||||
|
||||
Arguments:
|
||||
time: The time / date to convert
|
||||
target_tz: The desired timezone (string) - defaults to server time
|
||||
|
||||
Returns:
|
||||
A timezone aware datetime object, with the desired timezone
|
||||
|
||||
Raises:
|
||||
TypeError: If the provided time object is not a datetime or date object
|
||||
"""
|
||||
if isinstance(time, datetime.datetime):
|
||||
pass
|
||||
elif isinstance(time, datetime.date):
|
||||
time = timezone.datetime(year=time.year, month=time.month, day=time.day)
|
||||
else:
|
||||
raise TypeError(
|
||||
f'Argument must be a datetime or date object (found {type(time)}'
|
||||
)
|
||||
|
||||
# Extract timezone information from the provided time
|
||||
source_tz = getattr(time, 'tzinfo', None)
|
||||
|
||||
if not source_tz:
|
||||
# Default to UTC if not provided
|
||||
source_tz = pytz.utc
|
||||
|
||||
if not target_tz:
|
||||
target_tz = server_timezone()
|
||||
|
||||
try:
|
||||
target_tz = pytz.timezone(str(target_tz))
|
||||
except pytz.UnknownTimeZoneError:
|
||||
target_tz = pytz.utc
|
||||
|
||||
target_time = time.replace(tzinfo=source_tz).astimezone(target_tz)
|
||||
|
||||
return target_time
|
||||
|
||||
|
||||
def get_objectreference(
|
||||
obj, type_ref: str = 'content_type', object_ref: str = 'object_id'
|
||||
):
|
||||
@@ -840,7 +959,10 @@ def get_objectreference(
|
||||
return {'name': str(item), 'model': str(model_cls._meta.verbose_name), **ret}
|
||||
|
||||
|
||||
def inheritors(cls):
|
||||
Inheritors_T = TypeVar('Inheritors_T')
|
||||
|
||||
|
||||
def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
|
||||
"""Return all classes that are subclasses from the supplied cls."""
|
||||
subcls = set()
|
||||
work = [cls]
|
||||
@@ -857,3 +979,10 @@ def inheritors(cls):
|
||||
def is_ajax(request):
|
||||
"""Check if the current request is an AJAX request."""
|
||||
return request.headers.get('x-requested-with') == 'XMLHttpRequest'
|
||||
|
||||
|
||||
def pui_url(subpath: str) -> str:
|
||||
"""Return the URL for a PUI subpath."""
|
||||
if not subpath.startswith('/'):
|
||||
subpath = '/' + subpath
|
||||
return f'/{settings.FRONTEND_URL_BASE}{subpath}'
|
||||
|
106
InvenTree/InvenTree/helpers_mixin.py
Normal file
106
InvenTree/InvenTree/helpers_mixin.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Provides helper mixins that are used throughout the InvenTree project."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from plugin import registry as plg_registry
|
||||
|
||||
|
||||
class ClassValidationMixin:
|
||||
"""Mixin to validate class attributes and overrides.
|
||||
|
||||
Class attributes:
|
||||
required_attributes: List of class attributes that need to be defined
|
||||
required_overrides: List of functions that need override, a nested list mean either one of them needs an override
|
||||
|
||||
Example:
|
||||
```py
|
||||
class Parent(ClassValidationMixin):
|
||||
NAME: str
|
||||
def test(self):
|
||||
pass
|
||||
|
||||
required_attributes = ["NAME"]
|
||||
required_overrides = [test]
|
||||
|
||||
class MyClass(Parent):
|
||||
pass
|
||||
|
||||
myClass = MyClass()
|
||||
myClass.validate() # raises NotImplementedError
|
||||
```
|
||||
"""
|
||||
|
||||
required_attributes = []
|
||||
required_overrides = []
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
"""Validate the class against the required attributes/overrides."""
|
||||
|
||||
def attribute_missing(key):
|
||||
"""Check if attribute is missing."""
|
||||
return not hasattr(cls, key) or getattr(cls, key) == ''
|
||||
|
||||
def override_missing(base_implementation):
|
||||
"""Check if override is missing."""
|
||||
if isinstance(base_implementation, list):
|
||||
return all(override_missing(x) for x in base_implementation)
|
||||
|
||||
return base_implementation == getattr(
|
||||
cls, base_implementation.__name__, None
|
||||
)
|
||||
|
||||
missing_attributes = list(filter(attribute_missing, cls.required_attributes))
|
||||
missing_overrides = list(filter(override_missing, cls.required_overrides))
|
||||
|
||||
errors = []
|
||||
|
||||
if len(missing_attributes) > 0:
|
||||
errors.append(
|
||||
f"did not provide the following attributes: {', '.join(missing_attributes)}"
|
||||
)
|
||||
if len(missing_overrides) > 0:
|
||||
missing_overrides_list = []
|
||||
for base_implementation in missing_overrides:
|
||||
if isinstance(base_implementation, list):
|
||||
missing_overrides_list.append(
|
||||
'one of '
|
||||
+ ' or '.join(attr.__name__ for attr in base_implementation)
|
||||
)
|
||||
else:
|
||||
missing_overrides_list.append(base_implementation.__name__)
|
||||
errors.append(
|
||||
f"did not override the required attributes: {', '.join(missing_overrides_list)}"
|
||||
)
|
||||
|
||||
if len(errors) > 0:
|
||||
raise NotImplementedError(f"'{cls}' " + ' and '.join(errors))
|
||||
|
||||
|
||||
class ClassProviderMixin:
|
||||
"""Mixin to get metadata about a class itself, e.g. the plugin that provided that class."""
|
||||
|
||||
@classmethod
|
||||
def get_provider_file(cls):
|
||||
"""File that contains the Class definition."""
|
||||
return inspect.getfile(cls)
|
||||
|
||||
@classmethod
|
||||
def get_provider_plugin(cls):
|
||||
"""Plugin that contains the Class definition, otherwise None."""
|
||||
for plg in plg_registry.plugins.values():
|
||||
if plg.package_path == cls.__module__:
|
||||
return plg
|
||||
|
||||
@classmethod
|
||||
def get_is_builtin(cls):
|
||||
"""Is this Class build in the Inventree source code?"""
|
||||
try:
|
||||
Path(cls.get_provider_file()).relative_to(settings.BASE_DIR)
|
||||
return True
|
||||
except ValueError:
|
||||
# Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir
|
||||
return False
|
@@ -34,47 +34,59 @@ def getSetting(key, backup_value=None):
|
||||
return common.models.InvenTreeSetting.get_setting(key, backup_value=backup_value)
|
||||
|
||||
|
||||
def construct_absolute_url(*arg, **kwargs):
|
||||
def get_base_url(request=None):
|
||||
"""Return the base URL for the InvenTree server.
|
||||
|
||||
The base URL is determined in the following order of decreasing priority:
|
||||
|
||||
1. If a request object is provided, use the request URL
|
||||
2. Multi-site is enabled, and the current site has a valid URL
|
||||
3. If settings.SITE_URL is set (e.g. in the Django settings), use that
|
||||
4. If the InvenTree setting INVENTREE_BASE_URL is set, use that
|
||||
"""
|
||||
# Check if a request is provided
|
||||
if request:
|
||||
return request.build_absolute_uri('/')
|
||||
|
||||
# Check if multi-site is enabled
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
return Site.objects.get_current().domain
|
||||
except (ImportError, RuntimeError):
|
||||
pass
|
||||
|
||||
# Check if a global site URL is provided
|
||||
if site_url := getattr(settings, 'SITE_URL', None):
|
||||
return site_url
|
||||
|
||||
# Check if a global InvenTree setting is provided
|
||||
try:
|
||||
if site_url := common.models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_BASE_URL', create=False, cache=False
|
||||
):
|
||||
return site_url
|
||||
except (ProgrammingError, OperationalError):
|
||||
pass
|
||||
|
||||
# No base URL available
|
||||
return ''
|
||||
|
||||
|
||||
def construct_absolute_url(*arg, base_url=None, request=None):
|
||||
"""Construct (or attempt to construct) an absolute URL from a relative URL.
|
||||
|
||||
This is useful when (for example) sending an email to a user with a link
|
||||
to something in the InvenTree web framework.
|
||||
A URL is constructed in the following order:
|
||||
1. If settings.SITE_URL is set (e.g. in the Django settings), use that
|
||||
2. If the InvenTree setting INVENTREE_BASE_URL is set, use that
|
||||
3. Otherwise, use the current request URL (if available)
|
||||
Args:
|
||||
*arg: The relative URL to construct
|
||||
base_url: The base URL to use for the construction (if not provided, will attempt to determine from settings)
|
||||
request: The request object to use for the construction (optional)
|
||||
"""
|
||||
relative_url = '/'.join(arg)
|
||||
|
||||
# If a site URL is provided, use that
|
||||
site_url = getattr(settings, 'SITE_URL', None)
|
||||
if not base_url:
|
||||
base_url = get_base_url(request=request)
|
||||
|
||||
if not site_url:
|
||||
# Otherwise, try to use the InvenTree setting
|
||||
try:
|
||||
site_url = common.models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_BASE_URL', create=False, cache=False
|
||||
)
|
||||
except (ProgrammingError, OperationalError):
|
||||
pass
|
||||
|
||||
if not site_url:
|
||||
# Otherwise, try to use the current request
|
||||
request = kwargs.get('request', None)
|
||||
|
||||
if request:
|
||||
site_url = request.build_absolute_uri('/')
|
||||
|
||||
if not site_url:
|
||||
# No site URL available, return the relative URL
|
||||
return relative_url
|
||||
|
||||
return urljoin(site_url, relative_url)
|
||||
|
||||
|
||||
def get_base_url(**kwargs):
|
||||
"""Return the base URL for the InvenTree server."""
|
||||
return construct_absolute_url('', **kwargs)
|
||||
return urljoin(base_url, relative_url)
|
||||
|
||||
|
||||
def download_image_from_url(remote_url, timeout=2.5):
|
||||
@@ -192,6 +204,7 @@ def render_currency(
|
||||
currency=None,
|
||||
min_decimal_places=None,
|
||||
max_decimal_places=None,
|
||||
include_symbol=True,
|
||||
):
|
||||
"""Render a currency / Money object to a formatted string (e.g. for reports).
|
||||
|
||||
@@ -201,6 +214,7 @@ def render_currency(
|
||||
currency: Optionally convert to the specified currency
|
||||
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
|
||||
max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
||||
include_symbol: If True, include the currency symbol in the output
|
||||
"""
|
||||
if money in [None, '']:
|
||||
return '-'
|
||||
@@ -246,7 +260,9 @@ def render_currency(
|
||||
|
||||
decimal_places = max(decimal_places, max_decimal_places)
|
||||
|
||||
return format_money(money, decimal_places=decimal_places)
|
||||
return format_money(
|
||||
money, decimal_places=decimal_places, include_symbol=include_symbol
|
||||
)
|
||||
|
||||
|
||||
def getModelsWithMixin(mixin_class) -> list:
|
||||
@@ -287,6 +303,11 @@ def notify_responsible(
|
||||
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
|
||||
exclude (User, optional): User instance that should be excluded. Defaults to None.
|
||||
"""
|
||||
import InvenTree.ready
|
||||
|
||||
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
|
||||
return
|
||||
|
||||
notify_users(
|
||||
[instance.responsible], instance, sender, content=content, exclude=exclude
|
||||
)
|
||||
|
@@ -2,12 +2,14 @@
|
||||
|
||||
If a new language translation is supported, it must be added here
|
||||
After adding a new language, run the following command:
|
||||
|
||||
python manage.py makemessages -l <language_code> -e html,js,py --no-wrap
|
||||
where <language_code> is the code for the new language
|
||||
- where <language_code> is the code for the new language
|
||||
|
||||
Additionally, update the following files with the new locale code:
|
||||
|
||||
- /src/frontend/.linguirc file
|
||||
- /src/frontend/src/context/LanguageContext.tsx
|
||||
- /src/frontend/src/contexts/LanguageContext.tsx
|
||||
"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -30,12 +32,14 @@ LOCALES = [
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('ko', _('Korean')),
|
||||
('lv', _('Latvian')),
|
||||
('nl', _('Dutch')),
|
||||
('no', _('Norwegian')),
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portuguese')),
|
||||
('pt-br', _('Portuguese (Brazilian)')),
|
||||
('ru', _('Russian')),
|
||||
('sk', _('Slovak')),
|
||||
('sl', _('Slovenian')),
|
||||
('sr', _('Serbian')),
|
||||
('sv', _('Swedish')),
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
@@ -10,21 +9,23 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import sesame.utils
|
||||
from rest_framework import serializers
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import InvenTree.version
|
||||
|
||||
|
||||
def send_simple_login_email(user, link):
|
||||
"""Send an email with the login link to this user."""
|
||||
site = Site.objects.get_current()
|
||||
site_name = InvenTree.version.inventreeInstanceName()
|
||||
|
||||
context = {'username': user.username, 'site_name': site.name, 'link': link}
|
||||
context = {'username': user.username, 'site_name': site_name, 'link': link}
|
||||
email_plaintext_message = render_to_string(
|
||||
'InvenTree/user_simple_login.txt', context
|
||||
)
|
||||
|
||||
send_mail(
|
||||
_(f'[{site.name}] Log in to the app'),
|
||||
_(f'[{site_name}] Log in to the app'),
|
||||
email_plaintext_message,
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[user.email],
|
||||
@@ -37,7 +38,7 @@ class GetSimpleLoginSerializer(serializers.Serializer):
|
||||
email = serializers.CharField(label=_('Email'))
|
||||
|
||||
|
||||
class GetSimpleLoginView(APIView):
|
||||
class GetSimpleLoginView(GenericAPIView):
|
||||
"""View to send a simple login link."""
|
||||
|
||||
permission_classes = ()
|
||||
|
19
InvenTree/InvenTree/management/commands/check_migrations.py
Normal file
19
InvenTree/InvenTree/management/commands/check_migrations.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Check if there are any pending database migrations, and run them."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from InvenTree.tasks import check_for_migrations
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Check if there are any pending database migrations, and run them."""
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Check for any pending database migrations."""
|
||||
logger.info('Checking for pending database migrations')
|
||||
check_for_migrations(force=True, reload_registry=False)
|
||||
logger.info('Database migrations complete')
|
@@ -3,60 +3,73 @@
|
||||
- This is crucial after importing any fixtures, etc
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Rebuild all database models which leverage the MPTT structure."""
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Rebuild all database models which leverage the MPTT structure."""
|
||||
with maintenance_mode_on():
|
||||
self.rebuild_models()
|
||||
|
||||
set_maintenance_mode(False)
|
||||
|
||||
def rebuild_models(self):
|
||||
"""Rebuild all MPTT models in the database."""
|
||||
# Part model
|
||||
try:
|
||||
print('Rebuilding Part objects')
|
||||
logger.info('Rebuilding Part objects')
|
||||
|
||||
from part.models import Part
|
||||
|
||||
Part.objects.rebuild()
|
||||
except Exception:
|
||||
print('Error rebuilding Part objects')
|
||||
logger.info('Error rebuilding Part objects')
|
||||
|
||||
# Part category
|
||||
try:
|
||||
print('Rebuilding PartCategory objects')
|
||||
logger.info('Rebuilding PartCategory objects')
|
||||
|
||||
from part.models import PartCategory
|
||||
|
||||
PartCategory.objects.rebuild()
|
||||
except Exception:
|
||||
print('Error rebuilding PartCategory objects')
|
||||
logger.info('Error rebuilding PartCategory objects')
|
||||
|
||||
# StockItem model
|
||||
try:
|
||||
print('Rebuilding StockItem objects')
|
||||
logger.info('Rebuilding StockItem objects')
|
||||
|
||||
from stock.models import StockItem
|
||||
|
||||
StockItem.objects.rebuild()
|
||||
except Exception:
|
||||
print('Error rebuilding StockItem objects')
|
||||
logger.info('Error rebuilding StockItem objects')
|
||||
|
||||
# StockLocation model
|
||||
try:
|
||||
print('Rebuilding StockLocation objects')
|
||||
logger.info('Rebuilding StockLocation objects')
|
||||
|
||||
from stock.models import StockLocation
|
||||
|
||||
StockLocation.objects.rebuild()
|
||||
except Exception:
|
||||
print('Error rebuilding StockLocation objects')
|
||||
logger.info('Error rebuilding StockLocation objects')
|
||||
|
||||
# Build model
|
||||
try:
|
||||
print('Rebuilding Build objects')
|
||||
logger.info('Rebuilding Build objects')
|
||||
|
||||
from build.models import Build
|
||||
|
||||
Build.objects.rebuild()
|
||||
except Exception:
|
||||
print('Error rebuilding Build objects')
|
||||
logger.info('Error rebuilding Build objects')
|
||||
|
19
InvenTree/InvenTree/management/commands/runmigrations.py
Normal file
19
InvenTree/InvenTree/management/commands/runmigrations.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Check if there are any pending database migrations, and run them."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from InvenTree.tasks import check_for_migrations
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Check if there are any pending database migrations, and run them."""
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Check for any pending database migrations."""
|
||||
logger.info('Checking for pending database migrations')
|
||||
check_for_migrations(force=True, reload_registry=False)
|
||||
logger.info('Database migrations complete')
|
@@ -7,6 +7,7 @@ from rest_framework.fields import empty
|
||||
from rest_framework.metadata import SimpleMetadata
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
import common.models
|
||||
import InvenTree.permissions
|
||||
import users.models
|
||||
from InvenTree.helpers import str2bool
|
||||
@@ -208,7 +209,10 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
pk = kwargs[field]
|
||||
break
|
||||
|
||||
if pk is not None:
|
||||
if issubclass(model_class, common.models.BaseInvenTreeSetting):
|
||||
instance = model_class.get_setting_object(**kwargs, create=False)
|
||||
|
||||
elif pk is not None:
|
||||
try:
|
||||
instance = model_class.objects.get(pk=pk)
|
||||
except (ValueError, model_class.DoesNotExist):
|
||||
|
@@ -17,6 +17,23 @@ from users.models import ApiToken
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def get_token_from_request(request):
|
||||
"""Extract token information from a request object."""
|
||||
auth_keys = ['Authorization', 'authorization']
|
||||
token_keys = ['token', 'bearer']
|
||||
|
||||
for k in auth_keys:
|
||||
if auth_header := request.headers.get(k, None):
|
||||
auth_header = auth_header.strip().lower().split()
|
||||
|
||||
if len(auth_header) > 1:
|
||||
if auth_header[0].strip().lower().replace(':', '') in token_keys:
|
||||
token = auth_header[1]
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class AuthRequiredMiddleware(object):
|
||||
"""Check for user to be authenticated."""
|
||||
|
||||
@@ -24,6 +41,22 @@ class AuthRequiredMiddleware(object):
|
||||
"""Save response object."""
|
||||
self.get_response = get_response
|
||||
|
||||
def check_token(self, request) -> bool:
|
||||
"""Check if the user is authenticated via token."""
|
||||
if token := get_token_from_request(request):
|
||||
# Does the provided token match a valid user?
|
||||
try:
|
||||
token = ApiToken.objects.get(key=token)
|
||||
|
||||
if token.active and token.user:
|
||||
# Provide the user information to the request
|
||||
request.user = token.user
|
||||
return True
|
||||
except ApiToken.DoesNotExist:
|
||||
logger.warning('Access denied for unknown token %s', token)
|
||||
|
||||
return False
|
||||
|
||||
def __call__(self, request):
|
||||
"""Check if user needs to be authenticated and is.
|
||||
|
||||
@@ -40,6 +73,7 @@ class AuthRequiredMiddleware(object):
|
||||
|
||||
# Is the function exempt from auth requirements?
|
||||
path_func = resolve(request.path).func
|
||||
|
||||
if getattr(path_func, 'auth_exempt', False) is True:
|
||||
return self.get_response(request)
|
||||
|
||||
@@ -69,28 +103,8 @@ class AuthRequiredMiddleware(object):
|
||||
):
|
||||
authorized = True
|
||||
|
||||
elif (
|
||||
'Authorization' in request.headers.keys()
|
||||
or 'authorization' in request.headers.keys()
|
||||
):
|
||||
auth = request.headers.get(
|
||||
'Authorization', request.headers.get('authorization')
|
||||
).strip()
|
||||
|
||||
if auth.lower().startswith('token') and len(auth.split()) == 2:
|
||||
token_key = auth.split()[1]
|
||||
|
||||
# Does the provided token match a valid user?
|
||||
try:
|
||||
token = ApiToken.objects.get(key=token_key)
|
||||
|
||||
if token.active and token.user:
|
||||
# Provide the user information to the request
|
||||
request.user = token.user
|
||||
authorized = True
|
||||
|
||||
except ApiToken.DoesNotExist:
|
||||
logger.warning('Access denied for unknown token %s', token_key)
|
||||
elif self.check_token(request):
|
||||
authorized = True
|
||||
|
||||
# No authorization was found for the request
|
||||
if not authorized:
|
||||
@@ -105,7 +119,13 @@ class AuthRequiredMiddleware(object):
|
||||
]
|
||||
|
||||
# Do not redirect requests to any of these paths
|
||||
paths_ignore = ['/api/', '/js/', '/media/', '/static/']
|
||||
paths_ignore = [
|
||||
'/api/',
|
||||
'/auth/',
|
||||
'/js/',
|
||||
settings.MEDIA_URL,
|
||||
settings.STATIC_URL,
|
||||
]
|
||||
|
||||
if path not in urls and not any(
|
||||
path.startswith(p) for p in paths_ignore
|
||||
|
@@ -9,56 +9,6 @@ from InvenTree.fields import InvenTreeNotesField
|
||||
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
|
||||
|
||||
|
||||
class DiffMixin:
|
||||
"""Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
|
||||
|
||||
def get_db_instance(self):
|
||||
"""Return the instance of the object saved in the database.
|
||||
|
||||
Returns:
|
||||
object: Instance of the object saved in the database
|
||||
"""
|
||||
if self.pk:
|
||||
try:
|
||||
return self.__class__.objects.get(pk=self.pk)
|
||||
except self.__class__.DoesNotExist:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def get_field_deltas(self):
|
||||
"""Return a dict of field deltas.
|
||||
|
||||
Compares the current instance with the instance saved in the database,
|
||||
and returns a dict of fields which have changed.
|
||||
|
||||
Returns:
|
||||
dict: Dict of field deltas
|
||||
"""
|
||||
db_instance = self.get_db_instance()
|
||||
|
||||
if db_instance is None:
|
||||
return {}
|
||||
|
||||
deltas = {}
|
||||
|
||||
for field in self._meta.fields:
|
||||
if field.name == 'id':
|
||||
continue
|
||||
|
||||
if getattr(self, field.name) != getattr(db_instance, field.name):
|
||||
deltas[field.name] = {
|
||||
'old': getattr(db_instance, field.name),
|
||||
'new': getattr(self, field.name),
|
||||
}
|
||||
|
||||
return deltas
|
||||
|
||||
def has_field_changed(self, field_name):
|
||||
"""Determine if a particular field has changed."""
|
||||
return field_name in self.get_field_deltas()
|
||||
|
||||
|
||||
class CleanMixin:
|
||||
"""Model mixin class which cleans inputs using the Mozilla bleach tools."""
|
||||
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
@@ -30,18 +29,98 @@ from InvenTree.sanitizer import sanitize_svg
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def rename_attachment(instance, filename):
|
||||
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
|
||||
class DiffMixin:
|
||||
"""Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
|
||||
|
||||
Args:
|
||||
instance: Instance of a PartAttachment object
|
||||
filename: name of uploaded file
|
||||
def get_db_instance(self):
|
||||
"""Return the instance of the object saved in the database.
|
||||
|
||||
Returns:
|
||||
path to store file, format: '<subdir>/<id>/filename'
|
||||
Returns:
|
||||
object: Instance of the object saved in the database
|
||||
"""
|
||||
if self.pk:
|
||||
try:
|
||||
return self.__class__.objects.get(pk=self.pk)
|
||||
except self.__class__.DoesNotExist:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def get_field_deltas(self):
|
||||
"""Return a dict of field deltas.
|
||||
|
||||
Compares the current instance with the instance saved in the database,
|
||||
and returns a dict of fields which have changed.
|
||||
|
||||
Returns:
|
||||
dict: Dict of field deltas
|
||||
"""
|
||||
db_instance = self.get_db_instance()
|
||||
|
||||
if db_instance is None:
|
||||
return {}
|
||||
|
||||
deltas = {}
|
||||
|
||||
for field in self._meta.fields:
|
||||
if field.name == 'id':
|
||||
continue
|
||||
|
||||
if getattr(self, field.name) != getattr(db_instance, field.name):
|
||||
deltas[field.name] = {
|
||||
'old': getattr(db_instance, field.name),
|
||||
'new': getattr(self, field.name),
|
||||
}
|
||||
|
||||
return deltas
|
||||
|
||||
def has_field_changed(self, field_name):
|
||||
"""Determine if a particular field has changed."""
|
||||
return field_name in self.get_field_deltas()
|
||||
|
||||
|
||||
class PluginValidationMixin(DiffMixin):
|
||||
"""Mixin class which exposes the model instance to plugin validation.
|
||||
|
||||
Any model class which inherits from this mixin will be exposed to the plugin validation system.
|
||||
"""
|
||||
# Construct a path to store a file attachment for a given model type
|
||||
return os.path.join(instance.getSubdir(), filename)
|
||||
|
||||
def run_plugin_validation(self):
|
||||
"""Throw this model against the plugin validation interface."""
|
||||
from plugin.registry import registry
|
||||
|
||||
deltas = self.get_field_deltas()
|
||||
|
||||
for plugin in registry.with_mixin('validation'):
|
||||
try:
|
||||
if plugin.validate_model_instance(self, deltas=deltas) is True:
|
||||
return
|
||||
except ValidationError as exc:
|
||||
raise exc
|
||||
except Exception as exc:
|
||||
# Log the exception to the database
|
||||
import InvenTree.exceptions
|
||||
|
||||
InvenTree.exceptions.log_error(
|
||||
f'plugins.{plugin.slug}.validate_model_instance'
|
||||
)
|
||||
raise ValidationError(_('Error running plugin validation'))
|
||||
|
||||
def full_clean(self, *args, **kwargs):
|
||||
"""Run plugin validation on full model clean.
|
||||
|
||||
Note that plugin validation is performed *after* super.full_clean()
|
||||
"""
|
||||
super().full_clean(*args, **kwargs)
|
||||
self.run_plugin_validation()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Run plugin validation on model save.
|
||||
|
||||
Note that plugin validation is performed *before* super.save()
|
||||
"""
|
||||
self.run_plugin_validation()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class MetadataMixin(models.Model):
|
||||
@@ -377,7 +456,7 @@ class ReferenceIndexingMixin(models.Model):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reference_int = extract_int(reference)
|
||||
reference_int = InvenTree.helpers.extract_int(reference)
|
||||
|
||||
if validate:
|
||||
if reference_int > models.BigIntegerField.MAX_BIGINT:
|
||||
@@ -388,52 +467,44 @@ class ReferenceIndexingMixin(models.Model):
|
||||
reference_int = models.BigIntegerField(default=0)
|
||||
|
||||
|
||||
def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
|
||||
"""Extract an integer out of reference."""
|
||||
# Default value if we cannot convert to an integer
|
||||
ref_int = 0
|
||||
class InvenTreeModel(PluginValidationMixin, models.Model):
|
||||
"""Base class for InvenTree models, which provides some common functionality.
|
||||
|
||||
reference = str(reference).strip()
|
||||
Includes the following mixins by default:
|
||||
|
||||
# Ignore empty string
|
||||
if len(reference) == 0:
|
||||
return 0
|
||||
- PluginValidationMixin: Provides a hook for plugins to validate model instances
|
||||
"""
|
||||
|
||||
# Look at the start of the string - can it be "integerized"?
|
||||
result = re.match(r'^(\d+)', reference)
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
if result and len(result.groups()) == 1:
|
||||
ref = result.groups()[0]
|
||||
try:
|
||||
ref_int = int(ref)
|
||||
except Exception:
|
||||
ref_int = 0
|
||||
else:
|
||||
# Look at the "end" of the string
|
||||
result = re.search(r'(\d+)$', reference)
|
||||
|
||||
if result and len(result.groups()) == 1:
|
||||
ref = result.groups()[0]
|
||||
try:
|
||||
ref_int = int(ref)
|
||||
except Exception:
|
||||
ref_int = 0
|
||||
|
||||
# Ensure that the returned values are within the range that can be stored in an IntegerField
|
||||
# Note: This will result in large values being "clipped"
|
||||
if clip is not None:
|
||||
if ref_int > clip:
|
||||
ref_int = clip
|
||||
elif ref_int < -clip:
|
||||
ref_int = -clip
|
||||
|
||||
if not allow_negative and ref_int < 0:
|
||||
ref_int = abs(ref_int)
|
||||
|
||||
return ref_int
|
||||
abstract = True
|
||||
|
||||
|
||||
class InvenTreeAttachment(models.Model):
|
||||
class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
|
||||
"""Base class for an InvenTree model which includes a metadata field."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
def rename_attachment(instance, filename):
|
||||
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
|
||||
|
||||
Args:
|
||||
instance: Instance of a PartAttachment object
|
||||
filename: name of uploaded file
|
||||
|
||||
Returns:
|
||||
path to store file, format: '<subdir>/<id>/filename'
|
||||
"""
|
||||
# Construct a path to store a file attachment for a given model type
|
||||
return os.path.join(instance.getSubdir(), filename)
|
||||
|
||||
|
||||
class InvenTreeAttachment(InvenTreeModel):
|
||||
"""Provides an abstracted class for managing file attachments.
|
||||
|
||||
An attachment can be either an uploaded file, or an external URL
|
||||
@@ -615,7 +686,7 @@ class InvenTreeAttachment(models.Model):
|
||||
return ''
|
||||
|
||||
|
||||
class InvenTreeTree(MPTTModel):
|
||||
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
|
||||
"""Provides an abstracted self-referencing tree model for data categories.
|
||||
|
||||
- Each Category has one parent Category, which can be blank (for a top-level Category).
|
||||
|
@@ -69,6 +69,10 @@ class RolePermission(permissions.BasePermission):
|
||||
|
||||
# The required role may be defined for the view class
|
||||
if role := getattr(view, 'role_required', None):
|
||||
# If the role is specified as "role.permission", split it
|
||||
if '.' in role:
|
||||
role, permission = role.split('.')
|
||||
|
||||
return users.models.check_user_role(user, role, permission)
|
||||
|
||||
try:
|
||||
|
@@ -10,13 +10,64 @@ def isInTestMode():
|
||||
|
||||
|
||||
def isImportingData():
|
||||
"""Returns True if the database is currently importing data, e.g. 'loaddata' command is performed."""
|
||||
return 'loaddata' in sys.argv
|
||||
"""Returns True if the database is currently importing (or exporting) data, e.g. 'loaddata' command is performed."""
|
||||
return any((x in sys.argv for x in ['flush', 'loaddata', 'dumpdata']))
|
||||
|
||||
|
||||
def isRunningMigrations():
|
||||
"""Return True if the database is currently running migrations."""
|
||||
return any((x in sys.argv for x in ['migrate', 'makemigrations', 'showmigrations']))
|
||||
return any(
|
||||
(
|
||||
x in sys.argv
|
||||
for x in ['migrate', 'makemigrations', 'showmigrations', 'runmigrations']
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def isRebuildingData():
|
||||
"""Return true if any of the rebuilding commands are being executed."""
|
||||
return any(
|
||||
(
|
||||
x in sys.argv
|
||||
for x in ['prerender', 'rebuild_models', 'rebuild_thumbnails', 'rebuild']
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def isRunningBackup():
|
||||
"""Return true if any of the backup commands are being executed."""
|
||||
return any(
|
||||
(
|
||||
x in sys.argv
|
||||
for x in [
|
||||
'backup',
|
||||
'restore',
|
||||
'dbbackup',
|
||||
'dbresotore',
|
||||
'mediabackup',
|
||||
'mediarestore',
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def isInWorkerThread():
|
||||
"""Returns True if the current thread is a background worker thread."""
|
||||
return 'qcluster' in sys.argv
|
||||
|
||||
|
||||
def isInServerThread():
|
||||
"""Returns True if the current thread is a server thread."""
|
||||
if isInWorkerThread():
|
||||
return False
|
||||
|
||||
if 'runserver' in sys.argv:
|
||||
return True
|
||||
|
||||
if 'gunicorn' in sys.argv[0]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def isInMainThread():
|
||||
@@ -28,7 +79,7 @@ def isInMainThread():
|
||||
if 'runserver' in sys.argv and '--noreload' not in sys.argv:
|
||||
return os.environ.get('RUN_MAIN', None) == 'true'
|
||||
|
||||
return True
|
||||
return not isInWorkerThread()
|
||||
|
||||
|
||||
def canAppAccessDatabase(
|
||||
@@ -39,26 +90,30 @@ def canAppAccessDatabase(
|
||||
There are some circumstances where we don't want the ready function in apps.py
|
||||
to touch the database
|
||||
"""
|
||||
# Prevent database access if we are running backups
|
||||
if isRunningBackup():
|
||||
return False
|
||||
|
||||
# Prevent database access if we are importing data
|
||||
if isImportingData():
|
||||
return False
|
||||
|
||||
# Prevent database access if we are rebuilding data
|
||||
if isRebuildingData():
|
||||
return False
|
||||
|
||||
# Prevent database access if we are running migrations
|
||||
if not allow_plugins and isRunningMigrations():
|
||||
return False
|
||||
|
||||
# If any of the following management commands are being executed,
|
||||
# prevent custom "on load" code from running!
|
||||
excluded_commands = [
|
||||
'flush',
|
||||
'loaddata',
|
||||
'dumpdata',
|
||||
'check',
|
||||
'createsuperuser',
|
||||
'wait_for_db',
|
||||
'prerender',
|
||||
'rebuild_models',
|
||||
'rebuild_thumbnails',
|
||||
'makemessages',
|
||||
'compilemessages',
|
||||
'backup',
|
||||
'dbbackup',
|
||||
'mediabackup',
|
||||
'restore',
|
||||
'dbrestore',
|
||||
'mediarestore',
|
||||
]
|
||||
|
||||
if not allow_shell:
|
||||
@@ -69,12 +124,7 @@ def canAppAccessDatabase(
|
||||
excluded_commands.append('test')
|
||||
|
||||
if not allow_plugins:
|
||||
excluded_commands.extend([
|
||||
'makemigrations',
|
||||
'showmigrations',
|
||||
'migrate',
|
||||
'collectstatic',
|
||||
])
|
||||
excluded_commands.extend(['collectstatic'])
|
||||
|
||||
for cmd in excluded_commands:
|
||||
if cmd in sys.argv:
|
||||
|
@@ -7,7 +7,6 @@ from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -26,7 +25,10 @@ from taggit.serializers import TaggitSerializer
|
||||
import common.models as common_models
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
|
||||
from InvenTree.helpers_model import download_image_from_url
|
||||
|
||||
|
||||
class EmptySerializer(serializers.Serializer):
|
||||
"""Empty serializer for use in testing."""
|
||||
|
||||
|
||||
class InvenTreeMoneySerializer(MoneyField):
|
||||
@@ -155,8 +157,15 @@ class DependentField(serializers.Field):
|
||||
|
||||
# check if the request data contains the dependent fields, otherwise skip getting the child
|
||||
for f in self.depends_on:
|
||||
if not data.get(f, None):
|
||||
return
|
||||
if data.get(f, None) is None:
|
||||
if (
|
||||
self.parent
|
||||
and (v := getattr(self.parent.fields[f], 'default', None))
|
||||
is not None
|
||||
):
|
||||
data[f] = v
|
||||
else:
|
||||
return
|
||||
|
||||
# partially validate the data for options requests that set raise_exception while calling .get_child(...)
|
||||
if raise_exception:
|
||||
@@ -343,7 +352,12 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
try:
|
||||
instance.full_clean()
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
data = exc.message_dict
|
||||
if hasattr(exc, 'message_dict'):
|
||||
data = exc.message_dict
|
||||
elif hasattr(exc, 'message'):
|
||||
data = {'non_field_errors': [str(exc.message)]}
|
||||
else:
|
||||
data = {'non_field_errors': [str(exc)]}
|
||||
|
||||
# Change '__all__' key (django style) to 'non_field_errors' (DRF style)
|
||||
if '__all__' in data:
|
||||
@@ -445,19 +459,27 @@ class UserCreateSerializer(ExendedUserSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Send an e email to the user after creation."""
|
||||
from InvenTree.helpers_model import get_base_url
|
||||
|
||||
base_url = get_base_url()
|
||||
|
||||
instance = super().create(validated_data)
|
||||
|
||||
# Make sure the user cannot login until they have set a password
|
||||
instance.set_unusable_password()
|
||||
# Send the user an onboarding email (from current site)
|
||||
current_site = Site.objects.get_current()
|
||||
domain = current_site.domain
|
||||
instance.email_user(
|
||||
subject=_(f'Welcome to {current_site.name}'),
|
||||
message=_(
|
||||
f'Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain}).'
|
||||
),
|
||||
|
||||
message = (
|
||||
_('Your account has been created.')
|
||||
+ '\n\n'
|
||||
+ _('Please use the password reset function to login')
|
||||
)
|
||||
|
||||
if base_url:
|
||||
message += f'\n\nURL: {base_url}'
|
||||
|
||||
# Send the user an onboarding email (from current site)
|
||||
instance.email_user(subject=_('Welcome to InvenTree'), message=message)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
@@ -844,6 +866,8 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
|
||||
- Attempt to download the image and store it against this object instance
|
||||
- Catches and re-throws any errors
|
||||
"""
|
||||
from InvenTree.helpers_model import download_image_from_url
|
||||
|
||||
if not url:
|
||||
return
|
||||
|
||||
|
@@ -22,11 +22,12 @@ from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import moneyed
|
||||
import pytz
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
|
||||
from InvenTree.ready import isInMainThread
|
||||
from InvenTree.sentry import default_sentry_dsn, init_sentry
|
||||
from InvenTree.tracing import setup_instruments, setup_tracing
|
||||
from InvenTree.version import checkMinPythonVersion, inventreeApiVersion
|
||||
|
||||
from . import config, locales
|
||||
@@ -81,6 +82,10 @@ DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', True)
|
||||
ENABLE_CLASSIC_FRONTEND = get_boolean_setting(
|
||||
'INVENTREE_CLASSIC_FRONTEND', 'classic_frontend', True
|
||||
)
|
||||
|
||||
# Disable CUI parts if CUI tests are disabled
|
||||
if TESTING and '--exclude-tag=cui' in sys.argv:
|
||||
ENABLE_CLASSIC_FRONTEND = False
|
||||
ENABLE_PLATFORM_FRONTEND = get_boolean_setting(
|
||||
'INVENTREE_PLATFORM_FRONTEND', 'platform_frontend', True
|
||||
)
|
||||
@@ -121,43 +126,26 @@ STATIC_ROOT = config.get_static_dir()
|
||||
# The filesystem location for uploaded meadia files
|
||||
MEDIA_ROOT = config.get_media_dir()
|
||||
|
||||
# List of allowed hosts (default = allow all)
|
||||
ALLOWED_HOSTS = get_setting(
|
||||
'INVENTREE_ALLOWED_HOSTS',
|
||||
config_key='allowed_hosts',
|
||||
default_value=['*'],
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# Cross Origin Resource Sharing (CORS) options
|
||||
|
||||
# Only allow CORS access to API and media endpoints
|
||||
CORS_URLS_REGEX = r'^/(api|media|static)/.*$'
|
||||
|
||||
# Extract CORS options from configuration file
|
||||
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(
|
||||
'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=False
|
||||
)
|
||||
|
||||
CORS_ORIGIN_WHITELIST = get_setting(
|
||||
'INVENTREE_CORS_ORIGIN_WHITELIST',
|
||||
config_key='cors.whitelist',
|
||||
default_value=[],
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# Needed for the parts importer, directly impacts the maximum parts that can be uploaded
|
||||
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000
|
||||
|
||||
# Web URL endpoint for served static files
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
# Web URL endpoint for served media files
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
STATICFILES_DIRS = []
|
||||
|
||||
# Translated Template settings
|
||||
STATICFILES_I18_PREFIX = 'i18n'
|
||||
STATICFILES_I18_SRC = BASE_DIR.joinpath('templates', 'js', 'translated')
|
||||
STATICFILES_I18_TRG = BASE_DIR.joinpath('InvenTree', 'static_i18n')
|
||||
|
||||
# Create the target directory if it does not exist
|
||||
if not STATICFILES_I18_TRG.exists():
|
||||
STATICFILES_I18_TRG.mkdir(parents=True)
|
||||
|
||||
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
|
||||
STATICFILES_I18_TRG = STATICFILES_I18_TRG.joinpath(STATICFILES_I18_PREFIX)
|
||||
|
||||
@@ -172,9 +160,6 @@ STATFILES_I18_PROCESSORS = ['InvenTree.context.status_codes']
|
||||
# Color Themes Directory
|
||||
STATIC_COLOR_THEMES_DIR = STATIC_ROOT.joinpath('css', 'color-themes').resolve()
|
||||
|
||||
# Web URL endpoint for served media files
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
# Database backup options
|
||||
# Ref: https://django-dbbackup.readthedocs.io/en/master/configuration.html
|
||||
DBBACKUP_SEND_EMAIL = False
|
||||
@@ -214,6 +199,7 @@ INSTALLED_APPS = [
|
||||
'report.apps.ReportConfig',
|
||||
'stock.apps.StockConfig',
|
||||
'users.apps.UsersConfig',
|
||||
'machine.apps.MachineConfig',
|
||||
'web',
|
||||
'generic',
|
||||
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
|
||||
@@ -221,6 +207,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'user_sessions', # db user sessions
|
||||
'whitenoise.runserver_nostatic',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sites',
|
||||
@@ -260,9 +247,10 @@ MIDDLEWARE = CONFIG.get(
|
||||
'x_forwarded_for.middleware.XForwardedForMiddleware',
|
||||
'user_sessions.middleware.SessionMiddleware', # db user sessions
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth
|
||||
'allauth.account.middleware.AccountMiddleware',
|
||||
@@ -304,7 +292,10 @@ if LDAP_AUTH:
|
||||
|
||||
# get global options from dict and use ldap.OPT_* as keys and values
|
||||
global_options_dict = get_setting(
|
||||
'INVENTREE_LDAP_GLOBAL_OPTIONS', 'ldap.global_options', {}, dict
|
||||
'INVENTREE_LDAP_GLOBAL_OPTIONS',
|
||||
'ldap.global_options',
|
||||
default_value=None,
|
||||
typecast=dict,
|
||||
)
|
||||
global_options = {}
|
||||
for k, v in global_options_dict.items():
|
||||
@@ -374,24 +365,16 @@ if LDAP_AUTH:
|
||||
)
|
||||
AUTH_LDAP_DENY_GROUP = get_setting('INVENTREE_LDAP_DENY_GROUP', 'ldap.deny_group')
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = get_setting(
|
||||
'INVENTREE_LDAP_USER_FLAGS_BY_GROUP', 'ldap.user_flags_by_group', {}, dict
|
||||
'INVENTREE_LDAP_USER_FLAGS_BY_GROUP',
|
||||
'ldap.user_flags_by_group',
|
||||
default_value=None,
|
||||
typecast=dict,
|
||||
)
|
||||
AUTH_LDAP_FIND_GROUP_PERMS = True
|
||||
|
||||
# Internal IP addresses allowed to see the debug toolbar
|
||||
INTERNAL_IPS = ['127.0.0.1']
|
||||
|
||||
# Internal flag to determine if we are running in docker mode
|
||||
DOCKER = get_boolean_setting('INVENTREE_DOCKER', default_value=False)
|
||||
|
||||
if DOCKER: # pragma: no cover
|
||||
# Internal IP addresses are different when running under docker
|
||||
hostname, ___, ips = socket.gethostbyname_ex(socket.gethostname())
|
||||
INTERNAL_IPS = [ip[: ip.rfind('.')] + '.1' for ip in ips] + [
|
||||
'127.0.0.1',
|
||||
'10.0.2.2',
|
||||
]
|
||||
|
||||
# Allow secure http developer server in debug mode
|
||||
if DEBUG:
|
||||
INSTALLED_APPS.append('sslserver')
|
||||
@@ -438,9 +421,9 @@ REST_FRAMEWORK = {
|
||||
'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler',
|
||||
'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'users.authentication.ApiTokenAuthentication',
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'users.authentication.ApiTokenAuthentication',
|
||||
),
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
@@ -479,18 +462,6 @@ if USE_JWT:
|
||||
INSTALLED_APPS.append('rest_framework_simplejwt')
|
||||
|
||||
# WSGI default setting
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'InvenTree API',
|
||||
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
|
||||
'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'},
|
||||
'EXTERNAL_DOCS': {
|
||||
'docs': 'https://docs.inventree.org',
|
||||
'web': 'https://inventree.org',
|
||||
},
|
||||
'VERSION': inventreeApiVersion(),
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
}
|
||||
|
||||
WSGI_APPLICATION = 'InvenTree.wsgi.application'
|
||||
|
||||
"""
|
||||
@@ -504,7 +475,7 @@ Configure the database backend based on the user-specified values.
|
||||
logger.debug('Configuring database backend:')
|
||||
|
||||
# Extract database configuration from the config.yaml file
|
||||
db_config = CONFIG.get('database', {})
|
||||
db_config = CONFIG.get('database', None)
|
||||
|
||||
if not db_config:
|
||||
db_config = {}
|
||||
@@ -580,14 +551,14 @@ Ref: https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-OPTIONS
|
||||
# connecting to the database server (such as a replica failover) don't sit and
|
||||
# wait for possibly an hour or more, just tell the client something went wrong
|
||||
# and let the client retry when they want to.
|
||||
db_options = db_config.get('OPTIONS', db_config.get('options', {}))
|
||||
db_options = db_config.get('OPTIONS', db_config.get('options', None))
|
||||
|
||||
if db_options is None:
|
||||
db_options = {}
|
||||
|
||||
# Specific options for postgres backend
|
||||
if 'postgres' in db_engine: # pragma: no cover
|
||||
from psycopg2.extensions import (
|
||||
ISOLATION_LEVEL_READ_COMMITTED,
|
||||
ISOLATION_LEVEL_SERIALIZABLE,
|
||||
)
|
||||
from django.db.backends.postgresql.psycopg_any import IsolationLevel
|
||||
|
||||
# Connection timeout
|
||||
if 'connect_timeout' not in db_options:
|
||||
@@ -653,9 +624,9 @@ if 'postgres' in db_engine: # pragma: no cover
|
||||
'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False
|
||||
)
|
||||
db_options['isolation_level'] = (
|
||||
ISOLATION_LEVEL_SERIALIZABLE
|
||||
IsolationLevel.SERIALIZABLE
|
||||
if serializable
|
||||
else ISOLATION_LEVEL_READ_COMMITTED
|
||||
else IsolationLevel.READ_COMMITTED
|
||||
)
|
||||
|
||||
# Specific options for MySql / MariaDB backend
|
||||
@@ -732,7 +703,10 @@ if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover
|
||||
TRACING_ENABLED = get_boolean_setting(
|
||||
'INVENTREE_TRACING_ENABLED', 'tracing.enabled', False
|
||||
)
|
||||
|
||||
if TRACING_ENABLED: # pragma: no cover
|
||||
from InvenTree.tracing import setup_instruments, setup_tracing
|
||||
|
||||
_t_endpoint = get_setting('INVENTREE_TRACING_ENDPOINT', 'tracing.endpoint', None)
|
||||
_t_headers = get_setting('INVENTREE_TRACING_HEADERS', 'tracing.headers', None, dict)
|
||||
|
||||
@@ -743,7 +717,10 @@ if TRACING_ENABLED: # pragma: no cover
|
||||
logger.info('OpenTelemetry tracing enabled')
|
||||
|
||||
_t_resources = get_setting(
|
||||
'INVENTREE_TRACING_RESOURCES', 'tracing.resources', {}, dict
|
||||
'INVENTREE_TRACING_RESOURCES',
|
||||
'tracing.resources',
|
||||
default_value=None,
|
||||
typecast=dict,
|
||||
)
|
||||
cstm_tags = {'inventree.env.' + k: v for k, v in inventree_tags.items()}
|
||||
tracing_resources = {**cstm_tags, **_t_resources}
|
||||
@@ -755,7 +732,12 @@ if TRACING_ENABLED: # pragma: no cover
|
||||
console=get_boolean_setting(
|
||||
'INVENTREE_TRACING_CONSOLE', 'tracing.console', False
|
||||
),
|
||||
auth=get_setting('INVENTREE_TRACING_AUTH', 'tracing.auth', {}),
|
||||
auth=get_setting(
|
||||
'INVENTREE_TRACING_AUTH',
|
||||
'tracing.auth',
|
||||
default_value=None,
|
||||
typecast=dict,
|
||||
),
|
||||
is_http=get_setting('INVENTREE_TRACING_IS_HTTP', 'tracing.is_http', True),
|
||||
append_http=get_boolean_setting(
|
||||
'INVENTREE_TRACING_APPEND_HTTP', 'tracing.append_http', True
|
||||
@@ -814,7 +796,7 @@ Q_CLUSTER = {
|
||||
get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4)
|
||||
),
|
||||
'timeout': _q_worker_timeout,
|
||||
'retry': min(120, _q_worker_timeout + 30),
|
||||
'retry': max(120, _q_worker_timeout + 30),
|
||||
'max_attempts': int(
|
||||
get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5)
|
||||
),
|
||||
@@ -841,7 +823,8 @@ SESSION_ENGINE = 'user_sessions.backends.db'
|
||||
LOGOUT_REDIRECT_URL = get_setting(
|
||||
'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index'
|
||||
)
|
||||
SILENCED_SYSTEM_CHECKS = ['admin.E410']
|
||||
|
||||
SILENCED_SYSTEM_CHECKS = ['admin.E410', 'templates.E003', 'templates.W003']
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
|
||||
@@ -951,9 +934,13 @@ LOCALE_PATHS = (BASE_DIR.joinpath('locale/'),)
|
||||
|
||||
TIME_ZONE = get_setting('INVENTREE_TIMEZONE', 'timezone', 'UTC')
|
||||
|
||||
USE_I18N = True
|
||||
# Check that the timezone is valid
|
||||
try:
|
||||
pytz.timezone(TIME_ZONE)
|
||||
except pytz.exceptions.UnknownTimeZoneError: # pragma: no cover
|
||||
raise ValueError(f"Specified timezone '{TIME_ZONE}' is not valid")
|
||||
|
||||
USE_L10N = True
|
||||
USE_I18N = True
|
||||
|
||||
# Do not use native timezone support in "test" mode
|
||||
# It generates a *lot* of cruft in the logs
|
||||
@@ -968,13 +955,151 @@ CRISPY_TEMPLATE_PACK = 'bootstrap4'
|
||||
# Use database transactions when importing / exporting data
|
||||
IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||
|
||||
SITE_ID = 1
|
||||
# Site URL can be specified statically, or via a run-time setting
|
||||
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
|
||||
|
||||
if SITE_URL:
|
||||
logger.info('Using Site URL: %s', SITE_URL)
|
||||
|
||||
# Check that the site URL is valid
|
||||
validator = URLValidator()
|
||||
validator(SITE_URL)
|
||||
|
||||
# Enable or disable multi-site framework
|
||||
SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
|
||||
|
||||
# If a SITE_ID is specified
|
||||
SITE_ID = get_setting('INVENTREE_SITE_ID', 'site_id', 1 if SITE_MULTI else None)
|
||||
|
||||
# Load the allauth social backends
|
||||
SOCIAL_BACKENDS = get_setting(
|
||||
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
|
||||
)
|
||||
|
||||
if not SITE_MULTI:
|
||||
INSTALLED_APPS.remove('django.contrib.sites')
|
||||
|
||||
# List of allowed hosts (default = allow all)
|
||||
# Ref: https://docs.djangoproject.com/en/4.2/ref/settings/#allowed-hosts
|
||||
ALLOWED_HOSTS = get_setting(
|
||||
'INVENTREE_ALLOWED_HOSTS',
|
||||
config_key='allowed_hosts',
|
||||
default_value=[],
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
if SITE_URL and SITE_URL not in ALLOWED_HOSTS:
|
||||
ALLOWED_HOSTS.append(SITE_URL)
|
||||
|
||||
if not ALLOWED_HOSTS:
|
||||
if DEBUG:
|
||||
logger.info(
|
||||
'No ALLOWED_HOSTS specified. Defaulting to ["*"] for debug mode. This is not recommended for production use'
|
||||
)
|
||||
ALLOWED_HOSTS = ['*']
|
||||
elif not TESTING:
|
||||
logger.error(
|
||||
'No ALLOWED_HOSTS specified. Please provide a list of allowed hosts, or specify INVENTREE_SITE_URL'
|
||||
)
|
||||
|
||||
# Server cannot run without ALLOWED_HOSTS
|
||||
if isInMainThread():
|
||||
sys.exit(-1)
|
||||
|
||||
# Ensure that the ALLOWED_HOSTS do not contain any scheme info
|
||||
for i, host in enumerate(ALLOWED_HOSTS):
|
||||
if '://' in host:
|
||||
ALLOWED_HOSTS[i] = host.split('://')[1]
|
||||
|
||||
# List of trusted origins for unsafe requests
|
||||
# Ref: https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins
|
||||
CSRF_TRUSTED_ORIGINS = get_setting(
|
||||
'INVENTREE_TRUSTED_ORIGINS',
|
||||
config_key='trusted_origins',
|
||||
default_value=[],
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# If a list of trusted is not specified, but a site URL has been specified, use that
|
||||
if SITE_URL and SITE_URL not in CSRF_TRUSTED_ORIGINS:
|
||||
CSRF_TRUSTED_ORIGINS.append(SITE_URL)
|
||||
|
||||
if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
|
||||
if DEBUG:
|
||||
logger.warning(
|
||||
'No CSRF_TRUSTED_ORIGINS specified. Defaulting to http://* for debug mode. This is not recommended for production use'
|
||||
)
|
||||
CSRF_TRUSTED_ORIGINS = ['http://*']
|
||||
|
||||
elif isInMainThread():
|
||||
# Server thread cannot run without CSRF_TRUSTED_ORIGINS
|
||||
logger.error(
|
||||
'No CSRF_TRUSTED_ORIGINS specified. Please provide a list of trusted origins, or specify INVENTREE_SITE_URL'
|
||||
)
|
||||
sys.exit(-1)
|
||||
|
||||
USE_X_FORWARDED_HOST = get_boolean_setting(
|
||||
'INVENTREE_USE_X_FORWARDED_HOST',
|
||||
config_key='use_x_forwarded_host',
|
||||
default_value=False,
|
||||
)
|
||||
|
||||
USE_X_FORWARDED_PORT = get_boolean_setting(
|
||||
'INVENTREE_USE_X_FORWARDED_PORT',
|
||||
config_key='use_x_forwarded_port',
|
||||
default_value=False,
|
||||
)
|
||||
|
||||
# Cross Origin Resource Sharing (CORS) options
|
||||
# Refer to the django-cors-headers documentation for more information
|
||||
# Ref: https://github.com/adamchainz/django-cors-headers
|
||||
|
||||
# Extract CORS options from configuration file
|
||||
CORS_ALLOW_ALL_ORIGINS = get_boolean_setting(
|
||||
'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=DEBUG
|
||||
)
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = get_boolean_setting(
|
||||
'INVENTREE_CORS_ALLOW_CREDENTIALS',
|
||||
config_key='cors.allow_credentials',
|
||||
default_value=True,
|
||||
)
|
||||
|
||||
# Only allow CORS access to the following URL endpoints
|
||||
CORS_URLS_REGEX = r'^/(api|auth|media|static)/.*$'
|
||||
|
||||
CORS_ALLOWED_ORIGINS = get_setting(
|
||||
'INVENTREE_CORS_ORIGIN_WHITELIST',
|
||||
config_key='cors.whitelist',
|
||||
default_value=[],
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# If no CORS origins are specified, but a site URL has been specified, use that
|
||||
if SITE_URL and SITE_URL not in CORS_ALLOWED_ORIGINS:
|
||||
CORS_ALLOWED_ORIGINS.append(SITE_URL)
|
||||
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = get_setting(
|
||||
'INVENTREE_CORS_ORIGIN_REGEX',
|
||||
config_key='cors.regex',
|
||||
default_value=[],
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# In debug mode allow CORS requests from localhost
|
||||
# This allows connection from the frontend development server
|
||||
if DEBUG:
|
||||
CORS_ALLOWED_ORIGIN_REGEXES.append(r'^http://localhost:\d+$')
|
||||
|
||||
if CORS_ALLOW_ALL_ORIGINS:
|
||||
logger.info('CORS: All origins allowed')
|
||||
else:
|
||||
if CORS_ALLOWED_ORIGINS:
|
||||
logger.info('CORS: Whitelisted origins: %s', CORS_ALLOWED_ORIGINS)
|
||||
|
||||
if CORS_ALLOWED_ORIGIN_REGEXES:
|
||||
logger.info('CORS: Whitelisted origin regexes: %s', CORS_ALLOWED_ORIGIN_REGEXES)
|
||||
|
||||
for app in SOCIAL_BACKENDS:
|
||||
# Ensure that the app starts with 'allauth.socialaccount.providers'
|
||||
social_prefix = 'allauth.socialaccount.providers.'
|
||||
@@ -990,6 +1115,11 @@ SOCIALACCOUNT_PROVIDERS = get_setting(
|
||||
|
||||
SOCIALACCOUNT_STORE_TOKENS = True
|
||||
|
||||
# Explicitly set empty URL prefix for OIDC
|
||||
# The SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX setting was introduced in v0.60.0
|
||||
# Ref: https://github.com/pennersr/django-allauth/blob/0.60.0/ChangeLog.rst#backwards-incompatible-changes
|
||||
SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX = ''
|
||||
|
||||
# settings for allauth
|
||||
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting(
|
||||
'INVENTREE_LOGIN_CONFIRM_DAYS', 'login_confirm_days', 3, typecast=int
|
||||
@@ -1002,6 +1132,9 @@ ACCOUNT_DEFAULT_HTTP_PROTOCOL = get_setting(
|
||||
)
|
||||
ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
|
||||
ACCOUNT_PREVENT_ENUMERATION = True
|
||||
ACCOUNT_EMAIL_SUBJECT_PREFIX = EMAIL_SUBJECT_PREFIX
|
||||
# 2FA
|
||||
REMOVE_SUCCESS_URL = 'settings'
|
||||
|
||||
# override forms / adapters
|
||||
ACCOUNT_FORMS = {
|
||||
@@ -1056,13 +1189,16 @@ MARKDOWNIFY = {
|
||||
IGNORED_ERRORS = [Http404, django.core.exceptions.PermissionDenied]
|
||||
|
||||
# Maintenance mode
|
||||
MAINTENANCE_MODE_RETRY_AFTER = 60
|
||||
MAINTENANCE_MODE_STATE_BACKEND = 'maintenance_mode.backends.StaticStorageBackend'
|
||||
MAINTENANCE_MODE_RETRY_AFTER = 10
|
||||
MAINTENANCE_MODE_STATE_BACKEND = 'InvenTree.backends.InvenTreeMaintenanceModeBackend'
|
||||
|
||||
# Are plugins enabled?
|
||||
PLUGINS_ENABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGINS_ENABLED', 'plugins_enabled', False
|
||||
)
|
||||
PLUGINS_INSTALL_DISABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
|
||||
)
|
||||
|
||||
PLUGIN_FILE = config.get_plugin_file()
|
||||
|
||||
@@ -1079,15 +1215,8 @@ PLUGIN_RETRY = get_setting(
|
||||
) # How often should plugin loading be tried?
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
# Site URL can be specified statically, or via a run-time setting
|
||||
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
|
||||
|
||||
if SITE_URL:
|
||||
logger.info('Site URL: %s', SITE_URL)
|
||||
|
||||
# Check that the site URL is valid
|
||||
validator = URLValidator()
|
||||
validator(SITE_URL)
|
||||
# Flag to allow table events during testing
|
||||
TESTING_TABLE_EVENTS = False
|
||||
|
||||
# User interface customization values
|
||||
CUSTOM_LOGO = get_custom_file(
|
||||
@@ -1097,7 +1226,9 @@ CUSTOM_SPLASH = get_custom_file(
|
||||
'INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash'
|
||||
)
|
||||
|
||||
CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {})
|
||||
CUSTOMIZE = get_setting(
|
||||
'INVENTREE_CUSTOMIZE', 'customize', default_value=None, typecast=dict
|
||||
)
|
||||
|
||||
# Load settings for the frontend interface
|
||||
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
|
||||
@@ -1131,5 +1262,24 @@ if CUSTOM_FLAGS:
|
||||
|
||||
# Magic login django-sesame
|
||||
SESAME_MAX_AGE = 300
|
||||
# LOGIN_REDIRECT_URL = f"/{FRONTEND_URL_BASE}/logged-in/"
|
||||
LOGIN_REDIRECT_URL = '/index/'
|
||||
LOGIN_REDIRECT_URL = '/api/auth/login-redirect/'
|
||||
|
||||
# Configuratino for API schema generation
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'InvenTree API',
|
||||
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
|
||||
'LICENSE': {
|
||||
'name': 'MIT',
|
||||
'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE',
|
||||
},
|
||||
'EXTERNAL_DOCS': {
|
||||
'description': 'More information about InvenTree in the official docs',
|
||||
'url': 'https://docs.inventree.org',
|
||||
},
|
||||
'VERSION': str(inventreeApiVersion()),
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
'SCHEMA_PATH_PREFIX': '/api/',
|
||||
}
|
||||
|
||||
if SITE_URL and not TESTING:
|
||||
SPECTACULAR_SETTINGS['SERVERS'] = [{'url': SITE_URL}]
|
||||
|
@@ -9,6 +9,7 @@ from allauth.account.models import EmailAddress
|
||||
from allauth.socialaccount import providers
|
||||
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
@@ -16,7 +17,7 @@ from rest_framework.response import Response
|
||||
import InvenTree.sso
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -112,11 +113,36 @@ for name, provider in providers.registry.provider_map.items():
|
||||
social_auth_urlpatterns += provider_urlpatterns
|
||||
|
||||
|
||||
class SocialProviderListResponseSerializer(serializers.Serializer):
|
||||
"""Serializer for the SocialProviderListView."""
|
||||
|
||||
class SocialProvider(serializers.Serializer):
|
||||
"""Serializer for the SocialProviderListResponseSerializer."""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
configured = serializers.BooleanField()
|
||||
login = serializers.URLField()
|
||||
connect = serializers.URLField()
|
||||
display_name = serializers.CharField()
|
||||
|
||||
sso_enabled = serializers.BooleanField()
|
||||
sso_registration = serializers.BooleanField()
|
||||
mfa_required = serializers.BooleanField()
|
||||
providers = SocialProvider(many=True)
|
||||
registration_enabled = serializers.BooleanField()
|
||||
password_forgotten_enabled = serializers.BooleanField()
|
||||
|
||||
|
||||
class SocialProviderListView(ListAPI):
|
||||
"""List of available social providers."""
|
||||
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = EmptySerializer
|
||||
|
||||
@extend_schema(
|
||||
responses={200: OpenApiResponse(response=SocialProviderListResponseSerializer)}
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Get the list of providers."""
|
||||
provider_list = []
|
||||
@@ -153,6 +179,10 @@ class SocialProviderListView(ListAPI):
|
||||
'sso_registration': InvenTree.sso.registration_enabled(),
|
||||
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
|
||||
'providers': provider_list,
|
||||
'registration_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_REG'),
|
||||
'password_forgotten_enabled': InvenTreeSetting.get_setting(
|
||||
'LOGIN_ENABLE_PWD_FORGOT'
|
||||
),
|
||||
}
|
||||
return Response(data)
|
||||
|
||||
|
@@ -9,7 +9,7 @@ import time
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable, List
|
||||
from typing import Callable
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
@@ -180,6 +180,8 @@ def offload_task(
|
||||
Returns:
|
||||
bool: True if the task was offloaded (or ran), False otherwise
|
||||
"""
|
||||
from InvenTree.exceptions import log_error
|
||||
|
||||
try:
|
||||
import importlib
|
||||
|
||||
@@ -213,6 +215,7 @@ def offload_task(
|
||||
return False
|
||||
except Exception as exc:
|
||||
raise_warning(f"WARNING: '{taskname}' not offloaded due to {str(exc)}")
|
||||
log_error('InvenTree.offload_task')
|
||||
return False
|
||||
else:
|
||||
if callable(taskname):
|
||||
@@ -233,6 +236,7 @@ def offload_task(
|
||||
try:
|
||||
_mod = importlib.import_module(app_mod)
|
||||
except ModuleNotFoundError:
|
||||
log_error('InvenTree.offload_task')
|
||||
raise_warning(
|
||||
f"WARNING: '{taskname}' not started - No module named '{app_mod}'"
|
||||
)
|
||||
@@ -249,6 +253,7 @@ def offload_task(
|
||||
if not _func:
|
||||
_func = eval(func) # pragma: no cover
|
||||
except NameError:
|
||||
log_error('InvenTree.offload_task')
|
||||
raise_warning(
|
||||
f"WARNING: '{taskname}' not started - No function named '{func}'"
|
||||
)
|
||||
@@ -258,6 +263,7 @@ def offload_task(
|
||||
try:
|
||||
_func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
log_error('InvenTree.offload_task')
|
||||
raise_warning(f"WARNING: '{taskname}' not started due to {str(exc)}")
|
||||
return False
|
||||
|
||||
@@ -291,7 +297,7 @@ class ScheduledTask:
|
||||
class TaskRegister:
|
||||
"""Registry for periodic tasks."""
|
||||
|
||||
task_list: List[ScheduledTask] = []
|
||||
task_list: list[ScheduledTask] = []
|
||||
|
||||
def register(self, task, schedule, minutes: int = None):
|
||||
"""Register a task with the que."""
|
||||
@@ -644,7 +650,7 @@ def get_migration_plan():
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def check_for_migrations():
|
||||
def check_for_migrations(force: bool = False, reload_registry: bool = True):
|
||||
"""Checks if migrations are needed.
|
||||
|
||||
If the setting auto_update is enabled we will start updating.
|
||||
@@ -659,8 +665,9 @@ def check_for_migrations():
|
||||
|
||||
logger.info('Checking for pending database migrations')
|
||||
|
||||
# Force plugin registry reload
|
||||
registry.check_reload()
|
||||
if reload_registry:
|
||||
# Force plugin registry reload
|
||||
registry.check_reload()
|
||||
|
||||
plan = get_migration_plan()
|
||||
|
||||
@@ -674,7 +681,7 @@ def check_for_migrations():
|
||||
set_pending_migrations(n)
|
||||
|
||||
# Test if auto-updates are enabled
|
||||
if not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
|
||||
if not force and not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
|
||||
logger.info('Auto-update is disabled - skipping migrations')
|
||||
return
|
||||
|
||||
@@ -706,6 +713,7 @@ def check_for_migrations():
|
||||
set_maintenance_mode(False)
|
||||
logger.info('Manually released maintenance mode')
|
||||
|
||||
# We should be current now - triggering full reload to make sure all models
|
||||
# are loaded fully in their new state.
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
if reload_registry:
|
||||
# We should be current now - triggering full reload to make sure all models
|
||||
# are loaded fully in their new state.
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
@@ -52,7 +52,18 @@ class CustomTranslateNode(TranslateNode):
|
||||
# Escape any quotes contained in the string, if the request is for a javascript file
|
||||
request = context.get('request', None)
|
||||
|
||||
if self.escape or (request and request.path.endswith('.js')):
|
||||
template = getattr(context, 'template_name', None)
|
||||
request = context.get('request', None)
|
||||
|
||||
escape = self.escape
|
||||
|
||||
if template and str(template).endswith('.js'):
|
||||
escape = True
|
||||
|
||||
if request and str(request.path).endswith('.js'):
|
||||
escape = True
|
||||
|
||||
if escape:
|
||||
result = result.replace("'", r'\'')
|
||||
result = result.replace('"', r'\"')
|
||||
|
||||
|
@@ -155,6 +155,12 @@ def plugins_enabled(*args, **kwargs):
|
||||
return djangosettings.PLUGINS_ENABLED
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def plugins_install_disabled(*args, **kwargs):
|
||||
"""Return True if plugin install is disabled for the server instance."""
|
||||
return djangosettings.PLUGINS_INSTALL_DISABLED
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def plugins_info(*args, **kwargs):
|
||||
"""Return information about activated plugins."""
|
||||
@@ -334,7 +340,10 @@ def setting_object(key, *args, **kwargs):
|
||||
|
||||
plg = kwargs['plugin']
|
||||
if issubclass(plg.__class__, InvenTreePlugin):
|
||||
plg = plg.plugin_config()
|
||||
try:
|
||||
plg = plg.plugin_config()
|
||||
except plugin.models.PluginConfig.DoesNotExist:
|
||||
return None
|
||||
|
||||
return plugin.models.PluginSetting.get_setting_object(
|
||||
key, plugin=plg, cache=cache
|
||||
@@ -418,7 +427,7 @@ def progress_bar(val, max_val, *args, **kwargs):
|
||||
style_tags.append(f'max-width: {max_width};')
|
||||
|
||||
html = f"""
|
||||
<div id='{item_id}' class='progress' style='{" ".join(style_tags)}'>
|
||||
<div id='{item_id}' class='progress' style='{' '.join(style_tags)}'>
|
||||
<div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div>
|
||||
<div class='progress-value'>{val} / {max_val}</div>
|
||||
</div>
|
||||
@@ -503,17 +512,6 @@ def keyvalue(dict, key):
|
||||
return dict.get(key)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def call_method(obj, method_name, *args):
|
||||
"""Enables calling model methods / functions from templates with arguments.
|
||||
|
||||
Usage:
|
||||
{% call_method model_object 'fnc_name' argument1 %}
|
||||
"""
|
||||
method = getattr(obj, method_name)
|
||||
return method(*args)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def authorized_owners(group):
|
||||
"""Return authorized owners."""
|
||||
|
@@ -25,7 +25,7 @@ class HTMLAPITests(InvenTreeTestCase):
|
||||
url = reverse('api-part-list')
|
||||
|
||||
# Check JSON response
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json')
|
||||
response = self.client.get(url, headers={'accept': 'application/json'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_build_api(self):
|
||||
@@ -33,7 +33,7 @@ class HTMLAPITests(InvenTreeTestCase):
|
||||
url = reverse('api-build-list')
|
||||
|
||||
# Check JSON response
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json')
|
||||
response = self.client.get(url, headers={'accept': 'application/json'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_stock_api(self):
|
||||
@@ -41,7 +41,7 @@ class HTMLAPITests(InvenTreeTestCase):
|
||||
url = reverse('api-stock-list')
|
||||
|
||||
# Check JSON response
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json')
|
||||
response = self.client.get(url, headers={'accept': 'application/json'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_company_list(self):
|
||||
@@ -49,7 +49,7 @@ class HTMLAPITests(InvenTreeTestCase):
|
||||
url = reverse('api-company-list')
|
||||
|
||||
# Check JSON response
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json')
|
||||
response = self.client.get(url, headers={'accept': 'application/json'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_not_found(self):
|
||||
|
@@ -18,6 +18,11 @@ class ApiVersionTests(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(len(data), 10)
|
||||
|
||||
response = self.client.get(reverse('api-version'), format='json').json()
|
||||
self.assertIn('version', response)
|
||||
self.assertIn('dev', response)
|
||||
self.assertIn('up_to_date', response)
|
||||
|
||||
def test_inventree_api_text(self):
|
||||
"""Test that the inventreeApiText function works expected."""
|
||||
# Normal run
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
|
||||
from error_report.models import Error
|
||||
@@ -10,12 +11,16 @@ from InvenTree.exceptions import log_error
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
|
||||
# TODO change test to not rely on CUI
|
||||
@tag('cui')
|
||||
class MiddlewareTests(InvenTreeTestCase):
|
||||
"""Test for middleware functions."""
|
||||
|
||||
def check_path(self, url, code=200, **kwargs):
|
||||
"""Helper function to run a request."""
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json', **kwargs)
|
||||
response = self.client.get(
|
||||
url, headers={'accept': 'application/json'}, **kwargs
|
||||
)
|
||||
self.assertEqual(response.status_code, code)
|
||||
return response
|
||||
|
||||
|
@@ -4,10 +4,11 @@ import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, tag
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@tag('cui')
|
||||
class URLTest(TestCase):
|
||||
"""Test all files for broken url tags."""
|
||||
|
||||
|
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
@@ -35,6 +36,7 @@ class ViewTests(InvenTreeTestCase):
|
||||
|
||||
return str(response.content.decode())
|
||||
|
||||
@tag('cui')
|
||||
def test_panels(self):
|
||||
"""Test that the required 'panels' are present."""
|
||||
content = self.get_index_page()
|
||||
@@ -43,6 +45,7 @@ class ViewTests(InvenTreeTestCase):
|
||||
|
||||
# TODO: In future, run the javascript and ensure that the panels get created!
|
||||
|
||||
@tag('cui')
|
||||
def test_settings_page(self):
|
||||
"""Test that the 'settings' page loads correctly."""
|
||||
# Settings page loads
|
||||
@@ -101,6 +104,8 @@ class ViewTests(InvenTreeTestCase):
|
||||
self.assertNotIn(f'select-{panel}', content)
|
||||
self.assertNotIn(f'panel-{panel}', content)
|
||||
|
||||
# TODO: Replace this with a PUI test
|
||||
@tag('cui')
|
||||
def test_url_login(self):
|
||||
"""Test logging in via arguments."""
|
||||
# Log out
|
||||
|
@@ -10,16 +10,18 @@ from unittest import mock
|
||||
import django.core.exceptions as django_exceptions
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import mail
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import TestCase, override_settings, tag
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
import pint.errors
|
||||
import pytz
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import Rate, convert_money
|
||||
from djmoney.money import Money
|
||||
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
|
||||
from sesame.utils import get_user
|
||||
|
||||
import InvenTree.conversion
|
||||
@@ -29,6 +31,7 @@ import InvenTree.helpers_model
|
||||
import InvenTree.tasks
|
||||
from common.models import CustomUnit, InvenTreeSetting
|
||||
from common.settings import currency_codes
|
||||
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
from part.models import Part, PartCategory
|
||||
@@ -39,6 +42,147 @@ from .tasks import offload_task
|
||||
from .validators import validate_overage
|
||||
|
||||
|
||||
class HostTest(InvenTreeTestCase):
|
||||
"""Test for host configuration."""
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=['testserver'])
|
||||
def test_allowed_hosts(self):
|
||||
"""Test that the ALLOWED_HOSTS functions as expected."""
|
||||
self.assertIn('testserver', settings.ALLOWED_HOSTS)
|
||||
|
||||
response = self.client.get('/api/', headers={'host': 'testserver'})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get('/api/', headers={'host': 'invalidserver'})
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=['invalidserver.co.uk'])
|
||||
def test_allowed_hosts_2(self):
|
||||
"""Another test for ALLOWED_HOSTS functionality."""
|
||||
response = self.client.get('/api/', headers={'host': 'invalidserver.co.uk'})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class CorsTest(TestCase):
|
||||
"""Unit tests for CORS functionality."""
|
||||
|
||||
def cors_headers(self):
|
||||
"""Return a list of CORS headers."""
|
||||
return [
|
||||
'access-control-allow-origin',
|
||||
'access-control-allow-credentials',
|
||||
'access-control-allow-methods',
|
||||
'access-control-allow-headers',
|
||||
]
|
||||
|
||||
def preflight(self, url, origin, method='GET'):
|
||||
"""Make a CORS preflight request to the specified URL."""
|
||||
headers = {'origin': origin, 'access-control-request-method': method}
|
||||
|
||||
return self.client.options(url, headers=headers)
|
||||
|
||||
def test_no_origin(self):
|
||||
"""Test that CORS headers are not included for regular requests.
|
||||
|
||||
- We use the /api/ endpoint for this test (it does not require auth)
|
||||
- By default, in debug mode *all* CORS origins are allowed
|
||||
"""
|
||||
# Perform an initial response without the "origin" header
|
||||
response = self.client.get('/api/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
for header in self.cors_headers():
|
||||
self.assertNotIn(header, response.headers)
|
||||
|
||||
# Now, perform a "preflight" request with the "origin" header
|
||||
response = self.preflight('/api/', origin='http://random-external-server.com')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
for header in self.cors_headers():
|
||||
self.assertIn(header, response.headers)
|
||||
|
||||
self.assertEqual(response.headers['content-length'], '0')
|
||||
self.assertEqual(
|
||||
response.headers['access-control-allow-origin'],
|
||||
'http://random-external-server.com',
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
CORS_ALLOW_ALL_ORIGINS=False,
|
||||
CORS_ALLOWED_ORIGINS=['http://my-external-server.com'],
|
||||
CORS_ALLOWED_ORIGIN_REGEXES=[],
|
||||
)
|
||||
def test_auth_view(self):
|
||||
"""Test that CORS requests work for the /auth/ view.
|
||||
|
||||
Here, we are not authorized by default,
|
||||
but the CORS headers should still be included.
|
||||
"""
|
||||
url = '/auth/'
|
||||
|
||||
# First, a preflight request with a "valid" origin
|
||||
|
||||
response = self.preflight(url, origin='http://my-external-server.com')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
for header in self.cors_headers():
|
||||
self.assertIn(header, response.headers)
|
||||
|
||||
# Next, a preflight request with an "invalid" origin
|
||||
response = self.preflight(url, origin='http://random-external-server.com')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
for header in self.cors_headers():
|
||||
self.assertNotIn(header, response.headers)
|
||||
|
||||
# Next, make a GET request (without a token)
|
||||
response = self.client.get(
|
||||
url, headers={'origin': 'http://my-external-server.com'}
|
||||
)
|
||||
|
||||
# Unauthorized
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
self.assertIn('access-control-allow-origin', response.headers)
|
||||
self.assertNotIn('access-control-allow-methods', response.headers)
|
||||
|
||||
@override_settings(
|
||||
CORS_ALLOW_ALL_ORIGINS=False,
|
||||
CORS_ALLOWED_ORIGINS=[],
|
||||
CORS_ALLOWED_ORIGIN_REGEXES=['http://.*myserver.com'],
|
||||
)
|
||||
def test_cors_regex(self):
|
||||
"""Test that CORS regexes work as expected."""
|
||||
valid_urls = [
|
||||
'http://www.myserver.com',
|
||||
'http://test.myserver.com',
|
||||
'http://myserver.com',
|
||||
'http://www.myserver.com:8080',
|
||||
]
|
||||
|
||||
invalid_urls = [
|
||||
'http://myserver.org',
|
||||
'http://www.other-server.org',
|
||||
'http://google.com',
|
||||
'http://myserver.co.uk:8080',
|
||||
]
|
||||
|
||||
for url in valid_urls:
|
||||
response = self.preflight('/api/', origin=url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('access-control-allow-origin', response.headers)
|
||||
|
||||
for url in invalid_urls:
|
||||
response = self.preflight('/api/', origin=url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn('access-control-allow-origin', response.headers)
|
||||
|
||||
|
||||
class ConversionTest(TestCase):
|
||||
"""Tests for conversion of physical units."""
|
||||
|
||||
@@ -57,6 +201,68 @@ class ConversionTest(TestCase):
|
||||
q = InvenTree.conversion.convert_physical_value(val, 'm')
|
||||
self.assertAlmostEqual(q, expected, 3)
|
||||
|
||||
def test_engineering_units(self):
|
||||
"""Test that conversion works with engineering notation."""
|
||||
# Run some basic checks over the helper function
|
||||
tests = [
|
||||
('3', '3'),
|
||||
('3k3', '3.3k'),
|
||||
('123R45', '123.45R'),
|
||||
('10n5F', '10.5nF'),
|
||||
]
|
||||
|
||||
for val, expected in tests:
|
||||
self.assertEqual(
|
||||
InvenTree.conversion.from_engineering_notation(val), expected
|
||||
)
|
||||
|
||||
# Now test the conversion function
|
||||
tests = [('33k3ohm', 33300), ('123kohm45', 123450), ('10n005', 0.000000010005)]
|
||||
|
||||
for val, expected in tests:
|
||||
output = InvenTree.conversion.convert_physical_value(
|
||||
val, 'ohm', strip_units=True
|
||||
)
|
||||
self.assertAlmostEqual(output, expected, 12)
|
||||
|
||||
def test_scientific_notation(self):
|
||||
"""Test that scientific notation is handled correctly."""
|
||||
tests = [
|
||||
('3E2', 300),
|
||||
('-12.3E-3', -0.0123),
|
||||
('1.23E-3', 0.00123),
|
||||
('99E9', 99000000000),
|
||||
]
|
||||
|
||||
for val, expected in tests:
|
||||
output = InvenTree.conversion.convert_physical_value(val, strip_units=True)
|
||||
self.assertAlmostEqual(output, expected, 6)
|
||||
|
||||
def test_temperature_units(self):
|
||||
"""Test conversion of temperature units.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/issues/6495
|
||||
"""
|
||||
tests = [
|
||||
('3.3°F', '°C', -15.944),
|
||||
('273°K', '°F', 31.73),
|
||||
('900', '°C', 900),
|
||||
('900°F', 'degF', 900),
|
||||
('900°K', '°C', 626.85),
|
||||
('800', 'kelvin', 800),
|
||||
('-100°C', 'fahrenheit', -148),
|
||||
('-100 °C', 'Fahrenheit', -148),
|
||||
('-100 Celsius', 'fahrenheit', -148),
|
||||
('-123.45 fahrenheit', 'kelvin', 186.7888),
|
||||
('-99Fahrenheit', 'Celsius', -72.7777),
|
||||
]
|
||||
|
||||
for val, unit, expected in tests:
|
||||
output = InvenTree.conversion.convert_physical_value(
|
||||
val, unit, strip_units=True
|
||||
)
|
||||
self.assertAlmostEqual(output, expected, 3)
|
||||
|
||||
def test_base_units(self):
|
||||
"""Test conversion to specified base units."""
|
||||
tests = {
|
||||
@@ -75,6 +281,24 @@ class ConversionTest(TestCase):
|
||||
q = InvenTree.conversion.convert_physical_value(val, 'W', strip_units=False)
|
||||
self.assertAlmostEqual(float(q.magnitude), expected, places=2)
|
||||
|
||||
def test_imperial_lengths(self):
|
||||
"""Test support of imperial length measurements."""
|
||||
tests = [
|
||||
('1 inch', 'mm', 25.4),
|
||||
('1 "', 'mm', 25.4),
|
||||
('2 "', 'inches', 2),
|
||||
('3 feet', 'inches', 36),
|
||||
("3'", 'inches', 36),
|
||||
("7 '", 'feet', 7),
|
||||
]
|
||||
|
||||
for val, unit, expected in tests:
|
||||
output = InvenTree.conversion.convert_physical_value(
|
||||
val, unit, strip_units=True
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(output, expected, 3)
|
||||
|
||||
def test_dimensionless_units(self):
|
||||
"""Tests for 'dimensionless' unit quantities."""
|
||||
# Test some dimensionless units
|
||||
@@ -320,35 +544,37 @@ class FormatTest(TestCase):
|
||||
def test_currency_formatting(self):
|
||||
"""Test that currency formatting works correctly for multiple currencies."""
|
||||
test_data = (
|
||||
(Money(3651.285718, 'USD'), 4, '$3,651.2857'), # noqa: E201,E202
|
||||
(Money(487587.849178, 'CAD'), 5, 'CA$487,587.84918'), # noqa: E201,E202
|
||||
(Money(0.348102, 'EUR'), 1, '€0.3'), # noqa: E201,E202
|
||||
(Money(0.916530, 'GBP'), 1, '£0.9'), # noqa: E201,E202
|
||||
(Money(61.031024, 'JPY'), 3, '¥61.031'), # noqa: E201,E202
|
||||
(Money(49609.694602, 'JPY'), 1, '¥49,609.7'), # noqa: E201,E202
|
||||
(Money(155565.264777, 'AUD'), 2, 'A$155,565.26'), # noqa: E201,E202
|
||||
(Money(0.820437, 'CNY'), 4, 'CN¥0.8204'), # noqa: E201,E202
|
||||
(Money(7587.849178, 'EUR'), 0, '€7,588'), # noqa: E201,E202
|
||||
(Money(0.348102, 'GBP'), 3, '£0.348'), # noqa: E201,E202
|
||||
(Money(0.652923, 'CHF'), 0, 'CHF1'), # noqa: E201,E202
|
||||
(Money(0.820437, 'CNY'), 1, 'CN¥0.8'), # noqa: E201,E202
|
||||
(Money(98789.5295680, 'CHF'), 0, 'CHF98,790'), # noqa: E201,E202
|
||||
(Money(0.585787, 'USD'), 1, '$0.6'), # noqa: E201,E202
|
||||
(Money(0.690541, 'CAD'), 3, 'CA$0.691'), # noqa: E201,E202
|
||||
(Money(427.814104, 'AUD'), 5, 'A$427.81410'), # noqa: E201,E202
|
||||
(Money(3651.285718, 'USD'), 4, True, '$3,651.2857'), # noqa: E201,E202
|
||||
(Money(487587.849178, 'CAD'), 5, True, 'CA$487,587.84918'), # noqa: E201,E202
|
||||
(Money(0.348102, 'EUR'), 1, False, '0.3'), # noqa: E201,E202
|
||||
(Money(0.916530, 'GBP'), 1, True, '£0.9'), # noqa: E201,E202
|
||||
(Money(61.031024, 'JPY'), 3, False, '61.031'), # noqa: E201,E202
|
||||
(Money(49609.694602, 'JPY'), 1, True, '¥49,609.7'), # noqa: E201,E202
|
||||
(Money(155565.264777, 'AUD'), 2, False, '155,565.26'), # noqa: E201,E202
|
||||
(Money(0.820437, 'CNY'), 4, True, 'CN¥0.8204'), # noqa: E201,E202
|
||||
(Money(7587.849178, 'EUR'), 0, True, '€7,588'), # noqa: E201,E202
|
||||
(Money(0.348102, 'GBP'), 3, False, '0.348'), # noqa: E201,E202
|
||||
(Money(0.652923, 'CHF'), 0, True, 'CHF1'), # noqa: E201,E202
|
||||
(Money(0.820437, 'CNY'), 1, True, 'CN¥0.8'), # noqa: E201,E202
|
||||
(Money(98789.5295680, 'CHF'), 0, False, '98,790'), # noqa: E201,E202
|
||||
(Money(0.585787, 'USD'), 1, True, '$0.6'), # noqa: E201,E202
|
||||
(Money(0.690541, 'CAD'), 3, True, 'CA$0.691'), # noqa: E201,E202
|
||||
(Money(427.814104, 'AUD'), 5, True, 'A$427.81410'), # noqa: E201,E202
|
||||
)
|
||||
|
||||
with self.settings(LANGUAGE_CODE='en-us'):
|
||||
for value, decimal_places, expected_result in test_data:
|
||||
for value, decimal_places, include_symbol, expected_result in test_data:
|
||||
result = InvenTree.format.format_money(
|
||||
value, decimal_places=decimal_places
|
||||
value, decimal_places=decimal_places, include_symbol=include_symbol
|
||||
)
|
||||
assert result == expected_result
|
||||
|
||||
self.assertEqual(result, expected_result)
|
||||
|
||||
|
||||
class TestHelpers(TestCase):
|
||||
"""Tests for InvenTree helper functions."""
|
||||
|
||||
@override_settings(SITE_URL=None)
|
||||
def test_absolute_url(self):
|
||||
"""Test helper function for generating an absolute URL."""
|
||||
base = 'https://demo.inventree.org:12345'
|
||||
@@ -372,7 +598,7 @@ class TestHelpers(TestCase):
|
||||
for url, expected in tests.items():
|
||||
# Test with supplied base URL
|
||||
self.assertEqual(
|
||||
InvenTree.helpers_model.construct_absolute_url(url, site_url=base),
|
||||
InvenTree.helpers_model.construct_absolute_url(url, base_url=base),
|
||||
expected,
|
||||
)
|
||||
|
||||
@@ -508,6 +734,61 @@ class TestHelpers(TestCase):
|
||||
self.assertNotIn(PartCategory, models)
|
||||
self.assertNotIn(InvenTreeSetting, models)
|
||||
|
||||
def test_test_key(self):
|
||||
"""Test for the generateTestKey function."""
|
||||
tests = {
|
||||
' Hello World ': 'helloworld',
|
||||
' MY NEW TEST KEY ': 'mynewtestkey',
|
||||
' 1234 5678': '_12345678',
|
||||
' 100 percenT': '_100percent',
|
||||
' MY_NEW_TEST': 'my_new_test',
|
||||
' 100_new_tests': '_100_new_tests',
|
||||
}
|
||||
|
||||
for name, key in tests.items():
|
||||
self.assertEqual(helpers.generateTestKey(name), key)
|
||||
|
||||
|
||||
class TestTimeFormat(TestCase):
|
||||
"""Unit test for time formatting functionality."""
|
||||
|
||||
@override_settings(TIME_ZONE='UTC')
|
||||
def test_tz_utc(self):
|
||||
"""Check UTC timezone."""
|
||||
self.assertEqual(InvenTree.helpers.server_timezone(), 'UTC')
|
||||
|
||||
@override_settings(TIME_ZONE='Europe/London')
|
||||
def test_tz_london(self):
|
||||
"""Check London timezone."""
|
||||
self.assertEqual(InvenTree.helpers.server_timezone(), 'Europe/London')
|
||||
|
||||
@override_settings(TIME_ZONE='Australia/Sydney')
|
||||
def test_to_local_time(self):
|
||||
"""Test that the local time conversion works as expected."""
|
||||
source_time = timezone.datetime(
|
||||
year=2000,
|
||||
month=1,
|
||||
day=1,
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
tzinfo=pytz.timezone('Europe/London'),
|
||||
)
|
||||
|
||||
tests = [
|
||||
('UTC', '2000-01-01 00:01:00+00:00'),
|
||||
('Europe/London', '2000-01-01 00:00:00-00:01'),
|
||||
('America/New_York', '1999-12-31 19:01:00-05:00'),
|
||||
# All following tests should result in the same value
|
||||
('Australia/Sydney', '2000-01-01 11:01:00+11:00'),
|
||||
(None, '2000-01-01 11:01:00+11:00'),
|
||||
('', '2000-01-01 11:01:00+11:00'),
|
||||
]
|
||||
|
||||
for tz, expected in tests:
|
||||
local_time = InvenTree.helpers.to_local_time(source_time, tz)
|
||||
self.assertEqual(str(local_time), expected)
|
||||
|
||||
|
||||
class TestQuoteWrap(TestCase):
|
||||
"""Tests for string wrapping."""
|
||||
@@ -816,6 +1097,7 @@ class TestVersionNumber(TestCase):
|
||||
hash = str(
|
||||
subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8'
|
||||
).strip()
|
||||
|
||||
self.assertEqual(hash, version.inventreeCommitHash())
|
||||
|
||||
d = (
|
||||
@@ -990,9 +1272,12 @@ class TestSettings(InvenTreeTestCase):
|
||||
)
|
||||
|
||||
# with env set
|
||||
with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}):
|
||||
with self.in_env_context({
|
||||
'INVENTREE_CONFIG_FILE': '_testfolder/my_special_conf.yaml'
|
||||
}):
|
||||
self.assertIn(
|
||||
'inventree/my_special_conf.yaml', str(config.get_config_file()).lower()
|
||||
'inventree/_testfolder/my_special_conf.yaml',
|
||||
str(config.get_config_file()).lower(),
|
||||
)
|
||||
|
||||
def test_helpers_plugin_file(self):
|
||||
@@ -1006,8 +1291,12 @@ class TestSettings(InvenTreeTestCase):
|
||||
)
|
||||
|
||||
# with env set
|
||||
with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}):
|
||||
self.assertIn('my_special_plugins.txt', str(config.get_plugin_file()))
|
||||
with self.in_env_context({
|
||||
'INVENTREE_PLUGIN_FILE': '_testfolder/my_special_plugins.txt'
|
||||
}):
|
||||
self.assertIn(
|
||||
'_testfolder/my_special_plugins.txt', str(config.get_plugin_file())
|
||||
)
|
||||
|
||||
def test_helpers_setting(self):
|
||||
"""Test get_setting."""
|
||||
@@ -1049,10 +1338,17 @@ class TestInstanceName(InvenTreeTestCase):
|
||||
|
||||
self.assertEqual(version.inventreeInstanceTitle(), 'Testing title')
|
||||
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
except (ImportError, RuntimeError):
|
||||
# Multi-site support not enabled
|
||||
return
|
||||
|
||||
# The site should also be changed
|
||||
site_obj = Site.objects.all().order_by('id').first()
|
||||
self.assertEqual(site_obj.name, 'Testing title')
|
||||
|
||||
@override_settings(SITE_URL=None)
|
||||
def test_instance_url(self):
|
||||
"""Test instance url settings."""
|
||||
# Set up required setting
|
||||
@@ -1060,9 +1356,18 @@ class TestInstanceName(InvenTreeTestCase):
|
||||
'INVENTREE_BASE_URL', 'http://127.1.2.3', self.user
|
||||
)
|
||||
|
||||
# No further tests if multi-site support is not enabled
|
||||
if not settings.SITE_MULTI:
|
||||
return
|
||||
|
||||
# The site should also be changed
|
||||
site_obj = Site.objects.all().order_by('id').first()
|
||||
self.assertEqual(site_obj.domain, 'http://127.1.2.3')
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
site_obj = Site.objects.all().order_by('id').first()
|
||||
self.assertEqual(site_obj.domain, 'http://127.1.2.3')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestOffloadTask(InvenTreeTestCase):
|
||||
@@ -1234,7 +1539,7 @@ class MagicLoginTest(InvenTreeTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data, {'status': 'ok'})
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].subject, '[example.com] Log in to the app')
|
||||
self.assertEqual(mail.outbox[0].subject, '[InvenTree] Log in to the app')
|
||||
|
||||
# Check that the token is in the email
|
||||
self.assertTrue('http://testserver/api/email/login/' in mail.outbox[0].body)
|
||||
@@ -1247,9 +1552,155 @@ class MagicLoginTest(InvenTreeTestCase):
|
||||
# Check that the login works
|
||||
resp = self.client.get(reverse('sesame-login') + '?sesame=' + token)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.assertEqual(resp.url, '/index/')
|
||||
# Note: 2023-08-08 - This test has been changed because "platform UI" is not generally available yet
|
||||
# TODO: In the future, the URL comparison will need to be reverted
|
||||
# self.assertEqual(resp.url, f'/{settings.FRONTEND_URL_BASE}/logged-in/')
|
||||
self.assertEqual(resp.url, '/api/auth/login-redirect/')
|
||||
# And we should be logged in again
|
||||
self.assertEqual(resp.wsgi_request.user, self.user)
|
||||
|
||||
|
||||
# TODO - refactor to not use CUI
|
||||
@tag('cui')
|
||||
class MaintenanceModeTest(InvenTreeTestCase):
|
||||
"""Unit tests for maintenance mode."""
|
||||
|
||||
def test_basic(self):
|
||||
"""Test basic maintenance mode operation."""
|
||||
for value in [False, True, False]:
|
||||
set_maintenance_mode(value)
|
||||
self.assertEqual(get_maintenance_mode(), value)
|
||||
|
||||
# API request is blocked in maintenance mode
|
||||
set_maintenance_mode(True)
|
||||
|
||||
response = self.client.get('/api/')
|
||||
self.assertEqual(response.status_code, 503)
|
||||
|
||||
set_maintenance_mode(False)
|
||||
|
||||
response = self.client.get('/api/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_timestamp(self):
|
||||
"""Test that the timestamp value is interpreted correctly."""
|
||||
KEY = '_MAINTENANCE_MODE'
|
||||
|
||||
# Deleting the setting means maintenance mode is off
|
||||
InvenTreeSetting.objects.filter(key=KEY).delete()
|
||||
|
||||
self.assertFalse(get_maintenance_mode())
|
||||
|
||||
def set_timestamp(value):
|
||||
InvenTreeSetting.set_setting(KEY, value, None)
|
||||
|
||||
# Test blank value
|
||||
set_timestamp('')
|
||||
self.assertFalse(get_maintenance_mode())
|
||||
|
||||
# Test timestamp in the past
|
||||
ts = datetime.now() - timedelta(minutes=10)
|
||||
set_timestamp(ts.isoformat())
|
||||
self.assertFalse(get_maintenance_mode())
|
||||
|
||||
# Test timestamp in the future
|
||||
ts = datetime.now() + timedelta(minutes=10)
|
||||
set_timestamp(ts.isoformat())
|
||||
self.assertTrue(get_maintenance_mode())
|
||||
|
||||
# Set to false, check for empty string
|
||||
set_maintenance_mode(False)
|
||||
self.assertFalse(get_maintenance_mode())
|
||||
self.assertEqual(InvenTreeSetting.get_setting(KEY, None), '')
|
||||
|
||||
|
||||
class ClassValidationMixinTest(TestCase):
|
||||
"""Tests for the ClassValidationMixin class."""
|
||||
|
||||
class BaseTestClass(ClassValidationMixin):
|
||||
"""A valid class that inherits from ClassValidationMixin."""
|
||||
|
||||
NAME: str
|
||||
|
||||
def test(self):
|
||||
"""Test function."""
|
||||
pass
|
||||
|
||||
def test1(self):
|
||||
"""Test function."""
|
||||
pass
|
||||
|
||||
def test2(self):
|
||||
"""Test function."""
|
||||
pass
|
||||
|
||||
required_attributes = ['NAME']
|
||||
required_overrides = [test, [test1, test2]]
|
||||
|
||||
class InvalidClass:
|
||||
"""An invalid class that does not inherit from ClassValidationMixin."""
|
||||
|
||||
pass
|
||||
|
||||
def test_valid_class(self):
|
||||
"""Test that a valid class passes the validation."""
|
||||
|
||||
class TestClass(self.BaseTestClass):
|
||||
"""A valid class that inherits from BaseTestClass."""
|
||||
|
||||
NAME = 'Test'
|
||||
|
||||
def test(self):
|
||||
"""Test function."""
|
||||
pass
|
||||
|
||||
def test2(self):
|
||||
"""Test function."""
|
||||
pass
|
||||
|
||||
TestClass.validate()
|
||||
|
||||
def test_invalid_class(self):
|
||||
"""Test that an invalid class fails the validation."""
|
||||
|
||||
class TestClass1(self.BaseTestClass):
|
||||
"""A bad class that inherits from BaseTestClass."""
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
NotImplementedError,
|
||||
r'\'<.*TestClass1\'>\' did not provide the following attributes: NAME and did not override the required attributes: test, one of test1 or test2',
|
||||
):
|
||||
TestClass1.validate()
|
||||
|
||||
class TestClass2(self.BaseTestClass):
|
||||
"""A bad class that inherits from BaseTestClass."""
|
||||
|
||||
NAME = 'Test'
|
||||
|
||||
def test2(self):
|
||||
"""Test function."""
|
||||
pass
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
NotImplementedError,
|
||||
r'\'<.*TestClass2\'>\' did not override the required attributes: test',
|
||||
):
|
||||
TestClass2.validate()
|
||||
|
||||
|
||||
class ClassProviderMixinTest(TestCase):
|
||||
"""Tests for the ClassProviderMixin class."""
|
||||
|
||||
class TestClass(ClassProviderMixin):
|
||||
"""This class is a dummy class to test the ClassProviderMixin."""
|
||||
|
||||
pass
|
||||
|
||||
def test_get_provider_file(self):
|
||||
"""Test the get_provider_file function."""
|
||||
self.assertEqual(self.TestClass.get_provider_file(), __file__)
|
||||
|
||||
def test_provider_plugin(self):
|
||||
"""Test the provider_plugin function."""
|
||||
self.assertEqual(self.TestClass.get_provider_plugin(), None)
|
||||
|
||||
def test_get_is_builtin(self):
|
||||
"""Test the get_is_builtin function."""
|
||||
self.assertTrue(self.TestClass.get_is_builtin())
|
||||
|
@@ -19,6 +19,7 @@ from opentelemetry.sdk.metrics.export import (
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
||||
|
||||
import InvenTree.ready
|
||||
from InvenTree.version import inventreeVersion
|
||||
|
||||
# Logger configuration
|
||||
@@ -42,6 +43,9 @@ def setup_tracing(
|
||||
resources_input: The resources to send with the traces.
|
||||
console: Whether to output the traces to the console.
|
||||
"""
|
||||
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
|
||||
return
|
||||
|
||||
if resources_input is None:
|
||||
resources_input = {}
|
||||
if auth is None:
|
||||
|
@@ -22,6 +22,7 @@ import build.api
|
||||
import common.api
|
||||
import company.api
|
||||
import label.api
|
||||
import machine.api
|
||||
import order.api
|
||||
import part.api
|
||||
import plugin.api
|
||||
@@ -35,6 +36,7 @@ from order.urls import order_urls
|
||||
from part.urls import part_urls
|
||||
from plugin.urls import get_plugin_urls
|
||||
from stock.urls import stock_urls
|
||||
from web.urls import api_urls as web_api_urls
|
||||
from web.urls import urlpatterns as platform_urls
|
||||
|
||||
from .api import APISearchView, InfoView, NotFoundView, VersionTextView, VersionView
|
||||
@@ -82,8 +84,10 @@ apipatterns = [
|
||||
path('order/', include(order.api.order_api_urls)),
|
||||
path('label/', include(label.api.label_api_urls)),
|
||||
path('report/', include(report.api.report_api_urls)),
|
||||
path('machine/', include(machine.api.machine_api_urls)),
|
||||
path('user/', include(users.api.user_urls)),
|
||||
path('admin/', include(common.api.admin_api_urls)),
|
||||
path('web/', include(web_api_urls)),
|
||||
# Plugin endpoints
|
||||
path('', include(plugin.api.plugin_api_urls)),
|
||||
# Common endpoints endpoint
|
||||
@@ -148,6 +152,12 @@ apipatterns = [
|
||||
SocialAccountDisconnectView.as_view(),
|
||||
name='social_account_disconnect',
|
||||
),
|
||||
path('logout/', users.api.Logout.as_view(), name='api-logout'),
|
||||
path(
|
||||
'login-redirect/',
|
||||
users.api.LoginRedirect.as_view(),
|
||||
name='api-login-redirect',
|
||||
),
|
||||
path('', include('dj_rest_auth.urls')),
|
||||
]),
|
||||
),
|
||||
@@ -352,15 +362,19 @@ translated_javascript_urls = [
|
||||
]
|
||||
|
||||
backendpatterns = [
|
||||
# "Dynamic" javascript files which are rendered using InvenTree templating.
|
||||
path('js/dynamic/', include(dynamic_javascript_urls)),
|
||||
path('js/i18n/', include(translated_javascript_urls)),
|
||||
path('auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
path('auth/', auth_request),
|
||||
path('api/', include(apipatterns)),
|
||||
path('api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
|
||||
]
|
||||
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
# "Dynamic" javascript files which are rendered using InvenTree templating.
|
||||
backendpatterns += [
|
||||
re_path(r'^js/dynamic/', include(dynamic_javascript_urls)),
|
||||
re_path(r'^js/i18n/', include(translated_javascript_urls)),
|
||||
]
|
||||
|
||||
classic_frontendpatterns = [
|
||||
# Apps
|
||||
path('build/', include(build_urls)),
|
||||
@@ -425,6 +439,15 @@ if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
frontendpatterns += classic_frontendpatterns
|
||||
if settings.ENABLE_PLATFORM_FRONTEND:
|
||||
frontendpatterns += platform_urls
|
||||
if not settings.ENABLE_CLASSIC_FRONTEND:
|
||||
# Add a redirect for login views
|
||||
frontendpatterns += [
|
||||
path(
|
||||
'accounts/login/',
|
||||
RedirectView.as_view(url=settings.FRONTEND_URL_BASE, permanent=False),
|
||||
name='account_login',
|
||||
)
|
||||
]
|
||||
|
||||
urlpatterns += frontendpatterns
|
||||
|
||||
@@ -450,5 +473,14 @@ urlpatterns.append(
|
||||
|
||||
# Send any unknown URLs to the parts page
|
||||
urlpatterns += [
|
||||
re_path(r'^.*$', RedirectView.as_view(url='/index/', permanent=False), name='index')
|
||||
re_path(
|
||||
r'^.*$',
|
||||
RedirectView.as_view(
|
||||
url='/index/'
|
||||
if settings.ENABLE_CLASSIC_FRONTEND
|
||||
else settings.FRONTEND_URL_BASE,
|
||||
permanent=False,
|
||||
),
|
||||
name='index',
|
||||
)
|
||||
]
|
||||
|
@@ -19,7 +19,7 @@ from dulwich.repo import NotGitRepository, Repo
|
||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = '0.14.0 dev'
|
||||
INVENTREE_SW_VERSION = '0.15.0 dev'
|
||||
|
||||
# Discover git
|
||||
try:
|
||||
|
8
InvenTree/_testfolder/.gitignore
vendored
Normal file
8
InvenTree/_testfolder/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Files used for testing
|
||||
dummy_image.*
|
||||
_tmp.csv
|
||||
part_image_123abc.png
|
||||
label.pdf
|
||||
label.png
|
||||
my_special*
|
||||
_tests*.txt
|
@@ -51,6 +51,7 @@ class BuildResource(InvenTreeResource):
|
||||
notes = Field(attribute='notes')
|
||||
|
||||
|
||||
@admin.register(Build)
|
||||
class BuildAdmin(ImportExportModelAdmin):
|
||||
"""Class for managing the Build model via the admin interface"""
|
||||
|
||||
@@ -83,6 +84,7 @@ class BuildAdmin(ImportExportModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
@admin.register(BuildItem)
|
||||
class BuildItemAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the BuildItem model via the admin interface."""
|
||||
|
||||
@@ -98,6 +100,7 @@ class BuildItemAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
@admin.register(BuildLine)
|
||||
class BuildLineAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the BuildLine model via the admin interface"""
|
||||
|
||||
@@ -112,8 +115,3 @@ class BuildLineAdmin(admin.ModelAdmin):
|
||||
'build__reference',
|
||||
'bom_item__sub_part__name',
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(Build, BuildAdmin)
|
||||
admin.site.register(BuildItem, BuildItemAdmin)
|
||||
admin.site.register(BuildLine, BuildLineAdmin)
|
||||
|
@@ -314,11 +314,21 @@ class BuildLineEndpoint:
|
||||
queryset = BuildLine.objects.all()
|
||||
serializer_class = build.serializers.BuildLineSerializer
|
||||
|
||||
def get_source_build(self) -> Build:
|
||||
"""Return the source Build object for the BuildLine queryset.
|
||||
|
||||
This source build is used to filter the available stock for each BuildLine.
|
||||
|
||||
- If this is a "detail" view, use the build associated with the line
|
||||
- If this is a "list" view, use the build associated with the request
|
||||
"""
|
||||
raise NotImplementedError("get_source_build must be implemented in the child class")
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override queryset to select-related and annotate"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset)
|
||||
source_build = self.get_source_build()
|
||||
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -353,10 +363,26 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
|
||||
'bom_item__reference',
|
||||
]
|
||||
|
||||
def get_source_build(self) -> Build:
|
||||
"""Return the target build for the BuildLine queryset."""
|
||||
|
||||
try:
|
||||
build_id = self.request.query_params.get('build', None)
|
||||
if build_id:
|
||||
build = Build.objects.get(pk=build_id)
|
||||
return build
|
||||
except (Build.DoesNotExist, AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a BuildLine object."""
|
||||
pass
|
||||
|
||||
def get_source_build(self) -> Build:
|
||||
"""Return the target source location for the BuildLine queryset."""
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class BuildOrderContextMixin:
|
||||
|
@@ -3,12 +3,6 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import mptt.fields
|
||||
from build.models import Build
|
||||
|
||||
|
||||
def update_tree(apps, schema_editor):
|
||||
# Update the Build MPTT model
|
||||
Build.objects.rebuild()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -49,5 +43,4 @@ class Migration(migrations.Migration):
|
||||
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
|
@@ -57,6 +57,4 @@ class Migration(migrations.Migration):
|
||||
('build', '0028_builditem_bom_item'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(assign_bom_items, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
operations = []
|
||||
|
@@ -4,6 +4,7 @@ import decimal
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from django.conf import settings
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -28,7 +29,6 @@ from build.validators import generate_next_build_reference, validate_build_order
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.mixins
|
||||
import InvenTree.models
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
@@ -45,7 +45,7 @@ import users.models
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin):
|
||||
class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, MPTTModel):
|
||||
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||
|
||||
Attributes:
|
||||
@@ -162,7 +162,9 @@ class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBar
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Return the web URL associated with this BuildOrder"""
|
||||
return reverse('build-detail', kwargs={'pk': self.id})
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
return reverse('build-detail', kwargs={'pk': self.id})
|
||||
return InvenTree.helpers.pui_url(f'/build/{self.id}')
|
||||
|
||||
reference = models.CharField(
|
||||
unique=True,
|
||||
@@ -516,9 +518,25 @@ class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBar
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def complete_allocations(self, user):
|
||||
"""Complete all stock allocations for this build order.
|
||||
|
||||
- This function is called when a build order is completed
|
||||
"""
|
||||
# Remove untracked allocated stock
|
||||
self.subtract_allocated_stock(user)
|
||||
|
||||
# Ensure that there are no longer any BuildItem objects
|
||||
# which point to this Build Order
|
||||
self.allocated_stock.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def complete_build(self, user):
|
||||
"""Mark this build as complete."""
|
||||
|
||||
import build.tasks
|
||||
|
||||
if self.incomplete_count > 0:
|
||||
return
|
||||
|
||||
@@ -527,12 +545,12 @@ class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBar
|
||||
self.status = BuildStatus.COMPLETE.value
|
||||
self.save()
|
||||
|
||||
# Remove untracked allocated stock
|
||||
self.subtract_allocated_stock(user)
|
||||
|
||||
# Ensure that there are no longer any BuildItem objects
|
||||
# which point to this Build Order
|
||||
self.allocated_stock.delete()
|
||||
# Offload task to complete build allocations
|
||||
InvenTree.tasks.offload_task(
|
||||
build.tasks.complete_build_allocations,
|
||||
self.pk,
|
||||
user.pk if user else None
|
||||
)
|
||||
|
||||
# Register an event
|
||||
trigger_event('build.completed', id=self.pk)
|
||||
@@ -916,6 +934,11 @@ class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBar
|
||||
# List the allocated BuildItem objects for the given output
|
||||
allocated_items = output.items_to_install.all()
|
||||
|
||||
if (common.settings.prevent_build_output_complete_on_incompleted_tests() and output.hasRequiredTests() and not output.passedAllRequiredTests()):
|
||||
serial = output.serial
|
||||
raise ValidationError(
|
||||
_(f"Build output {serial} has not passed all required tests"))
|
||||
|
||||
for build_item in allocated_items:
|
||||
# Complete the allocation of stock for that item
|
||||
build_item.complete_allocation(user)
|
||||
@@ -1247,7 +1270,7 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
|
||||
|
||||
|
||||
class BuildLine(models.Model):
|
||||
class BuildLine(InvenTree.models.InvenTreeModel):
|
||||
"""A BuildLine object links a BOMItem to a Build.
|
||||
|
||||
When a new Build is created, the BuildLine objects are created automatically.
|
||||
@@ -1326,7 +1349,7 @@ class BuildLine(models.Model):
|
||||
return self.allocated_quantity() > self.quantity
|
||||
|
||||
|
||||
class BuildItem(InvenTree.models.MetadataMixin, models.Model):
|
||||
class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""A BuildItem links multiple StockItem objects to a Build.
|
||||
|
||||
These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed.
|
||||
|
@@ -1,5 +1,7 @@
|
||||
"""JSON serializers for Build API."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -7,22 +9,25 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
from django.db.models import ExpressionWrapper, F, FloatField
|
||||
from django.db.models import Case, Sum, When, Value
|
||||
from django.db.models import BooleanField
|
||||
from django.db.models import BooleanField, Q
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from sql_util.utils import SubquerySum
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import UserSerializer
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.serializers import InvenTreeDecimalField
|
||||
from InvenTree.status_codes import StockStatus
|
||||
from InvenTree.status_codes import BuildStatusGroups, StockStatus
|
||||
|
||||
from stock.models import generate_batch_code, StockItem, StockLocation
|
||||
from stock.serializers import StockItemSerializerBrief, LocationSerializer
|
||||
|
||||
import common.models
|
||||
from common.serializers import ProjectCodeSerializer
|
||||
import part.filters
|
||||
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
|
||||
@@ -519,6 +524,17 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
if common.settings.prevent_build_output_complete_on_incompleted_tests():
|
||||
errors = []
|
||||
for output in outputs:
|
||||
stock_item = output['output']
|
||||
if stock_item.hasRequiredTests() and not stock_item.passedAllRequiredTests():
|
||||
serial = stock_item.serial
|
||||
errors.append(_(f"Build output {serial} has not passed all required tests"))
|
||||
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
if len(outputs) == 0:
|
||||
raise ValidationError(_("A list of build outputs must be provided"))
|
||||
|
||||
@@ -904,18 +920,24 @@ class BuildAllocationSerializer(serializers.Serializer):
|
||||
if build_line.bom_item.consumable:
|
||||
continue
|
||||
|
||||
params = {
|
||||
"build_line": build_line,
|
||||
"stock_item": stock_item,
|
||||
"install_into": output,
|
||||
}
|
||||
|
||||
try:
|
||||
# Create a new BuildItem to allocate stock
|
||||
build_item, created = BuildItem.objects.get_or_create(
|
||||
build_line=build_line,
|
||||
stock_item=stock_item,
|
||||
install_into=output,
|
||||
)
|
||||
if created:
|
||||
build_item.quantity = quantity
|
||||
else:
|
||||
if build_item := BuildItem.objects.filter(**params).first():
|
||||
# Find an existing BuildItem for this stock item
|
||||
# If it exists, increase the quantity
|
||||
build_item.quantity += quantity
|
||||
build_item.save()
|
||||
build_item.save()
|
||||
else:
|
||||
# Create a new BuildItem to allocate stock
|
||||
build_item = BuildItem.objects.create(
|
||||
quantity=quantity,
|
||||
**params
|
||||
)
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
# Catch model errors and re-throw as DRF errors
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
@@ -1019,7 +1041,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
"""Determine which extra details fields should be included"""
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
location_detail = kwargs.pop('location_detail', True)
|
||||
stock_detail = kwargs.pop('stock_detail', False)
|
||||
stock_detail = kwargs.pop('stock_detail', True)
|
||||
build_detail = kwargs.pop('build_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1055,11 +1077,13 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
|
||||
# Annotated fields
|
||||
'allocated',
|
||||
'in_production',
|
||||
'on_order',
|
||||
'available_stock',
|
||||
'available_substitute_stock',
|
||||
'available_variant_stock',
|
||||
'total_available_stock',
|
||||
'external_stock',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
@@ -1070,26 +1094,54 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
|
||||
|
||||
# Foreign key fields
|
||||
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
|
||||
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
||||
allocations = BuildItemSerializer(many=True, read_only=True)
|
||||
|
||||
# Annotated (calculated) fields
|
||||
allocated = serializers.FloatField(read_only=True)
|
||||
on_order = serializers.FloatField(read_only=True)
|
||||
available_stock = serializers.FloatField(read_only=True)
|
||||
allocated = serializers.FloatField(
|
||||
label=_('Allocated Stock'),
|
||||
read_only=True
|
||||
)
|
||||
|
||||
on_order = serializers.FloatField(
|
||||
label=_('On Order'),
|
||||
read_only=True
|
||||
)
|
||||
|
||||
in_production = serializers.FloatField(
|
||||
label=_('In Production'),
|
||||
read_only=True
|
||||
)
|
||||
|
||||
available_stock = serializers.FloatField(
|
||||
label=_('Available Stock'),
|
||||
read_only=True
|
||||
)
|
||||
|
||||
available_substitute_stock = serializers.FloatField(read_only=True)
|
||||
available_variant_stock = serializers.FloatField(read_only=True)
|
||||
total_available_stock = serializers.FloatField(read_only=True)
|
||||
external_stock = serializers.FloatField(read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
def annotate_queryset(queryset, build=None):
|
||||
"""Add extra annotations to the queryset:
|
||||
|
||||
- allocated: Total stock quantity allocated against this build line
|
||||
- available: Total stock available for allocation against this build line
|
||||
- on_order: Total stock on order for this build line
|
||||
- in_production: Total stock currently in production for this build line
|
||||
|
||||
Arguments:
|
||||
queryset: The queryset to annotate
|
||||
build: The build order to filter against (optional)
|
||||
|
||||
Note: If the 'build' is provided, we can use it to filter available stock, depending on the specified location for the build
|
||||
|
||||
"""
|
||||
queryset = queryset.select_related(
|
||||
'build', 'bom_item',
|
||||
@@ -1126,6 +1178,23 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
|
||||
ref = 'bom_item__sub_part__'
|
||||
|
||||
stock_filter = None
|
||||
|
||||
if build is not None and build.take_from is not None:
|
||||
location = build.take_from
|
||||
# Filter by locations below the specified location
|
||||
stock_filter = Q(
|
||||
location__tree_id=location.tree_id,
|
||||
location__lft__gte=location.lft,
|
||||
location__rght__lte=location.rght,
|
||||
location__level__gte=location.level,
|
||||
)
|
||||
|
||||
# Annotate the "in_production" quantity
|
||||
queryset = queryset.annotate(
|
||||
in_production=part.filters.annotate_in_production_quantity(reference=ref)
|
||||
)
|
||||
|
||||
# Annotate the "on_order" quantity
|
||||
# Difficulty: Medium
|
||||
queryset = queryset.annotate(
|
||||
@@ -1133,10 +1202,8 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
# Annotate the "available" quantity
|
||||
# TODO: In the future, this should be refactored.
|
||||
# TODO: Note that part.serializers.BomItemSerializer also has a similar annotation
|
||||
queryset = queryset.alias(
|
||||
total_stock=part.filters.annotate_total_stock(reference=ref),
|
||||
total_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter),
|
||||
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref),
|
||||
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref),
|
||||
)
|
||||
@@ -1149,11 +1216,21 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
)
|
||||
|
||||
external_stock_filter = Q(location__external=True)
|
||||
|
||||
if stock_filter:
|
||||
external_stock_filter &= stock_filter
|
||||
|
||||
# Add 'external stock' annotations
|
||||
queryset = queryset.annotate(
|
||||
external_stock=part.filters.annotate_total_stock(reference=ref, filter=external_stock_filter)
|
||||
)
|
||||
|
||||
ref = 'bom_item__substitutes__part__'
|
||||
|
||||
# Extract similar information for any 'substitute' parts
|
||||
queryset = queryset.alias(
|
||||
substitute_stock=part.filters.annotate_total_stock(reference=ref),
|
||||
substitute_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter),
|
||||
substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref),
|
||||
substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref)
|
||||
)
|
||||
@@ -1167,7 +1244,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
# Annotate the queryset with 'available variant stock' information
|
||||
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__')
|
||||
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__', filter=stock_filter)
|
||||
|
||||
queryset = queryset.alias(
|
||||
variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
|
||||
|
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
@@ -24,6 +25,27 @@ import part.models as part_models
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def complete_build_allocations(build_id: int, user_id: int):
|
||||
"""Complete build allocations for a specified BuildOrder."""
|
||||
|
||||
build_order = build.models.Build.objects.filter(pk=build_id).first()
|
||||
|
||||
if user_id:
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
logger.warning("Could not complete build allocations for BuildOrder <%s> - User does not exist", build_id)
|
||||
return
|
||||
else:
|
||||
user = None
|
||||
|
||||
if not build_order:
|
||||
logger.warning("Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist", build_id)
|
||||
return
|
||||
|
||||
build_order.complete_allocations(user)
|
||||
|
||||
|
||||
def update_build_order_lines(bom_item_pk: int):
|
||||
"""Update all BuildOrderLineItem objects which reference a particular BomItem.
|
||||
|
||||
|
@@ -200,6 +200,11 @@
|
||||
<div id='build-lines-toolbar'>
|
||||
{% include "filter_list.html" with id='buildlines' %}
|
||||
</div>
|
||||
{% if build.take_from %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "Available stock has been filtered based on specified source location for this build order" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,7 +378,14 @@ onPanelLoad('allocate', function() {
|
||||
loadBuildLineTable(
|
||||
"#build-lines-table",
|
||||
{{ build.pk }},
|
||||
{}
|
||||
{
|
||||
{% if build.take_from %}
|
||||
location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
{% if build.project_code %}
|
||||
project_code: {{ build.project_code.pk }},
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
@@ -822,6 +822,58 @@ class BuildAllocationTest(BuildAPITest):
|
||||
allocation.refresh_from_db()
|
||||
self.assertEqual(allocation.quantity, 5000)
|
||||
|
||||
def test_fractional_allocation(self):
|
||||
"""Test allocation of a fractional quantity of stock items.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/issues/6508
|
||||
"""
|
||||
|
||||
si = StockItem.objects.get(pk=2)
|
||||
|
||||
# Find line item
|
||||
line = self.build.build_lines.all().filter(bom_item__sub_part=si.part).first()
|
||||
|
||||
# Test a fractional quantity when the *available* quantity is greater than 1
|
||||
si.quantity = 100
|
||||
si.save()
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"build_line": line.pk,
|
||||
"stock_item": si.pk,
|
||||
"quantity": 0.1616,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# Test a fractional quantity when the *available* quantity is less than 1
|
||||
si = StockItem.objects.create(
|
||||
part=si.part,
|
||||
quantity=0.3159,
|
||||
tree_id=0,
|
||||
level=0,
|
||||
lft=0, rght=0
|
||||
)
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"build_line": line.pk,
|
||||
"stock_item": si.pk,
|
||||
"quantity": 0.1616,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
|
||||
class BuildOverallocationTest(BuildAPITest):
|
||||
"""Unit tests for over allocation of stock items against a build order.
|
||||
|
@@ -1,5 +1,5 @@
|
||||
"""Unit tests for the 'build' models"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
@@ -14,8 +14,8 @@ from InvenTree import status_codes as status
|
||||
import common.models
|
||||
import build.tasks
|
||||
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
|
||||
from part.models import Part, BomItem, BomItemSubstitute
|
||||
from stock.models import StockItem
|
||||
from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
|
||||
from stock.models import StockItem, StockItemTestResult
|
||||
from users.models import Owner
|
||||
|
||||
import logging
|
||||
@@ -55,6 +55,76 @@ class BuildTestBase(TestCase):
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
# create one build with one required test template
|
||||
cls.tested_part_with_required_test = Part.objects.create(
|
||||
name="Part having required tests",
|
||||
description="Why does it matter what my description is?",
|
||||
assembly=True,
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
cls.test_template_required = PartTestTemplate.objects.create(
|
||||
part=cls.tested_part_with_required_test,
|
||||
test_name="Required test",
|
||||
description="Required test template description",
|
||||
required=True,
|
||||
requires_value=False,
|
||||
requires_attachment=False
|
||||
)
|
||||
|
||||
ref = generate_next_build_reference()
|
||||
|
||||
cls.build_w_tests_trackable = Build.objects.create(
|
||||
reference=ref,
|
||||
title="This is a build",
|
||||
part=cls.tested_part_with_required_test,
|
||||
quantity=1,
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
)
|
||||
|
||||
cls.stockitem_with_required_test = StockItem.objects.create(
|
||||
part=cls.tested_part_with_required_test,
|
||||
quantity=1,
|
||||
is_building=True,
|
||||
serial=uuid.uuid4(),
|
||||
build=cls.build_w_tests_trackable
|
||||
)
|
||||
|
||||
# now create a part with a non-required test template
|
||||
cls.tested_part_wo_required_test = Part.objects.create(
|
||||
name="Part with one non.required test",
|
||||
description="Why does it matter what my description is?",
|
||||
assembly=True,
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
cls.test_template_non_required = PartTestTemplate.objects.create(
|
||||
part=cls.tested_part_wo_required_test,
|
||||
test_name="Required test template",
|
||||
description="Required test template description",
|
||||
required=False,
|
||||
requires_value=False,
|
||||
requires_attachment=False
|
||||
)
|
||||
|
||||
ref = generate_next_build_reference()
|
||||
|
||||
cls.build_wo_tests_trackable = Build.objects.create(
|
||||
reference=ref,
|
||||
title="This is a build",
|
||||
part=cls.tested_part_wo_required_test,
|
||||
quantity=1,
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
)
|
||||
|
||||
cls.stockitem_wo_required_test = StockItem.objects.create(
|
||||
part=cls.tested_part_wo_required_test,
|
||||
quantity=1,
|
||||
is_building=True,
|
||||
serial=uuid.uuid4(),
|
||||
build=cls.build_wo_tests_trackable
|
||||
)
|
||||
|
||||
cls.sub_part_1 = Part.objects.create(
|
||||
name="Widget A",
|
||||
description="A widget",
|
||||
@@ -245,7 +315,7 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
def test_init(self):
|
||||
"""Perform some basic tests before we start the ball rolling"""
|
||||
self.assertEqual(StockItem.objects.count(), 10)
|
||||
self.assertEqual(StockItem.objects.count(), 12)
|
||||
|
||||
# Build is PENDING
|
||||
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
|
||||
@@ -558,7 +628,7 @@ class BuildTest(BuildTestBase):
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
|
||||
# New stock items should have been created!
|
||||
self.assertEqual(StockItem.objects.count(), 13)
|
||||
self.assertEqual(StockItem.objects.count(), 15)
|
||||
|
||||
# This stock item has been marked as "consumed"
|
||||
item = StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||
@@ -573,6 +643,27 @@ class BuildTest(BuildTestBase):
|
||||
for output in outputs:
|
||||
self.assertFalse(output.is_building)
|
||||
|
||||
def test_complete_with_required_tests(self):
|
||||
"""Test the prevention completion when a required test is missing feature"""
|
||||
|
||||
# with required tests incompleted the save should fail
|
||||
common.models.InvenTreeSetting.set_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
|
||||
|
||||
# let's complete the required test and see if it could be saved
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=self.stockitem_with_required_test,
|
||||
template=self.test_template_required,
|
||||
result=True
|
||||
)
|
||||
|
||||
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
|
||||
|
||||
# let's see if a non required test could be saved
|
||||
self.build_wo_tests_trackable.complete_build_output(self.stockitem_wo_required_test, None)
|
||||
|
||||
def test_overdue_notification(self):
|
||||
"""Test sending of notifications when a build order is overdue."""
|
||||
self.build.target_date = datetime.now().date() - timedelta(days=1)
|
||||
|
@@ -1,5 +1,7 @@
|
||||
"""Basic unit tests for the BuildOrder app"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@@ -40,7 +42,8 @@ class BuildTestSimple(InvenTreeTestCase):
|
||||
def test_url(self):
|
||||
"""Test URL lookup"""
|
||||
b1 = Build.objects.get(pk=1)
|
||||
self.assertEqual(b1.get_absolute_url(), '/build/1/')
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
self.assertEqual(b1.get_absolute_url(), '/build/1/')
|
||||
|
||||
def test_is_complete(self):
|
||||
"""Test build completion status"""
|
||||
@@ -116,11 +119,13 @@ class TestBuildViews(InvenTreeTestCase):
|
||||
is_building=True,
|
||||
)
|
||||
|
||||
@tag('cui')
|
||||
def test_build_index(self):
|
||||
"""Test build index view."""
|
||||
response = self.client.get(reverse('build-index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@tag('cui')
|
||||
def test_build_detail(self):
|
||||
"""Test the detail view for a Build object."""
|
||||
pk = 1
|
||||
|
@@ -11,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
import django_q.models
|
||||
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 rest_framework import permissions, serializers
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound
|
||||
@@ -53,7 +54,15 @@ class WebhookView(CsrfExemptMixin, APIView):
|
||||
permission_classes = []
|
||||
model_class = common.models.WebhookEndpoint
|
||||
run_async = False
|
||||
serializer_class = None
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description='Any data can be posted to the endpoint - everything will be passed to the WebhookEndpoint model.'
|
||||
)
|
||||
}
|
||||
)
|
||||
def post(self, request, endpoint, *args, **kwargs):
|
||||
"""Process incoming webhook."""
|
||||
# get webhook definition
|
||||
@@ -115,6 +124,7 @@ class CurrencyExchangeView(APIView):
|
||||
"""API endpoint for displaying currency information."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = None
|
||||
|
||||
def get(self, request, format=None):
|
||||
"""Return information on available currency conversions."""
|
||||
@@ -157,6 +167,7 @@ class CurrencyRefreshView(APIView):
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser]
|
||||
serializer_class = None
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Performing a POST request will update currency exchange rates."""
|
||||
@@ -516,6 +527,7 @@ class BackgroundTaskOverview(APIView):
|
||||
"""Provides an overview of the background task queue status."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
|
||||
serializer_class = None
|
||||
|
||||
def get(self, request, format=None):
|
||||
"""Return information about the current status of the background task queue."""
|
||||
@@ -668,6 +680,13 @@ common_api_urls = [
|
||||
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'error-report/',
|
||||
include([
|
||||
path('<int:pk>/', ErrorMessageDetail.as_view(), name='api-error-detail'),
|
||||
path('', ErrorMessageList.as_view(), name='api-error-list'),
|
||||
]),
|
||||
),
|
||||
# Project codes
|
||||
path(
|
||||
'project-code/',
|
||||
|
@@ -13,10 +13,10 @@ import math
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from secrets import compare_digest
|
||||
from typing import Any, Callable, Dict, List, Tuple, TypedDict, Union
|
||||
from typing import Any, Callable, TypedDict, Union
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
@@ -24,7 +24,6 @@ from django.contrib.auth.models import Group, User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import AppRegistryNotReady, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
|
||||
@@ -101,6 +100,10 @@ class BaseURLValidator(URLValidator):
|
||||
"""Make sure empty values pass."""
|
||||
value = str(value).strip()
|
||||
|
||||
# If a configuration level value has been specified, prevent change
|
||||
if settings.SITE_URL and value != settings.SITE_URL:
|
||||
raise ValidationError(_('Site URL is locked by configuration'))
|
||||
|
||||
if len(value) == 0:
|
||||
pass
|
||||
|
||||
@@ -108,7 +111,7 @@ class BaseURLValidator(URLValidator):
|
||||
super().__call__(value)
|
||||
|
||||
|
||||
class ProjectCode(InvenTree.models.MetadataMixin, models.Model):
|
||||
class ProjectCode(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""A ProjectCode is a unique identifier for a project."""
|
||||
|
||||
@staticmethod
|
||||
@@ -154,7 +157,7 @@ class SettingsKeyType(TypedDict, total=False):
|
||||
units: Units of the particular setting (optional)
|
||||
validator: Validation function/list of functions for the setting (optional, default: None, e.g: bool, int, str, MinValueValidator, ...)
|
||||
default: Default value or function that returns default value (optional)
|
||||
choices: (Function that returns) Tuple[str: key, str: display value] (optional)
|
||||
choices: Function that returns or value of list[tuple[str: key, str: display value]] (optional)
|
||||
hidden: Hide this setting from settings page (optional)
|
||||
before_save: Function that gets called after save with *args, **kwargs (optional)
|
||||
after_save: Function that gets called after save with *args, **kwargs (optional)
|
||||
@@ -166,9 +169,9 @@ class SettingsKeyType(TypedDict, total=False):
|
||||
name: str
|
||||
description: str
|
||||
units: str
|
||||
validator: Union[Callable, List[Callable], Tuple[Callable]]
|
||||
validator: Union[Callable, list[Callable], tuple[Callable]]
|
||||
default: Union[Callable, Any]
|
||||
choices: Union[Tuple[str, str], Callable[[], Tuple[str, str]]]
|
||||
choices: Union[list[tuple[str, str]], Callable[[], list[tuple[str, str]]]]
|
||||
hidden: bool
|
||||
before_save: Callable[..., None]
|
||||
after_save: Callable[..., None]
|
||||
@@ -185,9 +188,9 @@ class BaseInvenTreeSetting(models.Model):
|
||||
extra_unique_fields: List of extra fields used to be unique, e.g. for PluginConfig -> plugin
|
||||
"""
|
||||
|
||||
SETTINGS: Dict[str, SettingsKeyType] = {}
|
||||
SETTINGS: dict[str, SettingsKeyType] = {}
|
||||
|
||||
extra_unique_fields: List[str] = []
|
||||
extra_unique_fields: list[str] = []
|
||||
|
||||
class Meta:
|
||||
"""Meta options for BaseInvenTreeSetting -> abstract stops creation of database entry."""
|
||||
@@ -223,9 +226,12 @@ class BaseInvenTreeSetting(models.Model):
|
||||
"""
|
||||
cache_key = f'BUILD_DEFAULT_VALUES:{str(cls.__name__)}'
|
||||
|
||||
if InvenTree.helpers.str2bool(cache.get(cache_key, False)):
|
||||
# Already built default values
|
||||
return
|
||||
try:
|
||||
if InvenTree.helpers.str2bool(cache.get(cache_key, False)):
|
||||
# Already built default values
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
existing_keys = cls.objects.filter(**kwargs).values_list('key', flat=True)
|
||||
@@ -248,7 +254,10 @@ class BaseInvenTreeSetting(models.Model):
|
||||
)
|
||||
pass
|
||||
|
||||
cache.set(cache_key, True, timeout=3600)
|
||||
try:
|
||||
cache.set(cache_key, True, timeout=3600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _call_settings_function(self, reference: str, args, kwargs):
|
||||
"""Call a function associated with a particular setting.
|
||||
@@ -287,8 +296,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
try:
|
||||
cache.set(ckey, self, timeout=3600)
|
||||
except TypeError:
|
||||
# Some characters cause issues with caching; ignore and move on
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@@ -329,7 +337,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
cls,
|
||||
*,
|
||||
exclude_hidden=False,
|
||||
settings_definition: Union[Dict[str, SettingsKeyType], None] = None,
|
||||
settings_definition: Union[dict[str, SettingsKeyType], None] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Return a list of "all" defined settings.
|
||||
@@ -349,7 +357,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
# Optionally filter by other keys
|
||||
results = results.filter(**filters)
|
||||
|
||||
settings: Dict[str, BaseInvenTreeSetting] = {}
|
||||
settings: dict[str, BaseInvenTreeSetting] = {}
|
||||
|
||||
# Query the database
|
||||
for setting in results:
|
||||
@@ -391,7 +399,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
cls,
|
||||
*,
|
||||
exclude_hidden=False,
|
||||
settings_definition: Union[Dict[str, SettingsKeyType], None] = None,
|
||||
settings_definition: Union[dict[str, SettingsKeyType], None] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Return a dict of "all" defined global settings.
|
||||
@@ -406,7 +414,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
settings: Dict[str, Any] = {}
|
||||
settings: dict[str, Any] = {}
|
||||
|
||||
for key, setting in all_settings.items():
|
||||
settings[key] = setting.value
|
||||
@@ -418,7 +426,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
cls,
|
||||
*,
|
||||
exclude_hidden=False,
|
||||
settings_definition: Union[Dict[str, SettingsKeyType], None] = None,
|
||||
settings_definition: Union[dict[str, SettingsKeyType], None] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Check if all required settings are set by definition.
|
||||
@@ -433,7 +441,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
missing_settings: List[str] = []
|
||||
missing_settings: list[str] = []
|
||||
|
||||
for setting in all_settings.values():
|
||||
if setting.required:
|
||||
@@ -522,7 +530,11 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
if callable(choices):
|
||||
# Evaluate the function (we expect it will return a list of tuples...)
|
||||
return choices()
|
||||
try:
|
||||
# Attempt to pass the kwargs to the function, if it doesn't expect them, ignore and call without
|
||||
return choices(**kwargs)
|
||||
except TypeError:
|
||||
return choices()
|
||||
|
||||
return choices
|
||||
|
||||
@@ -547,16 +559,18 @@ class BaseInvenTreeSetting(models.Model):
|
||||
# Unless otherwise specified, attempt to create the setting
|
||||
create = kwargs.pop('create', True)
|
||||
|
||||
# Perform cache lookup by default
|
||||
do_cache = kwargs.pop('cache', True)
|
||||
|
||||
# Prevent saving to the database during data import
|
||||
if InvenTree.ready.isImportingData():
|
||||
create = False
|
||||
do_cache = False
|
||||
|
||||
# Prevent saving to the database during migrations
|
||||
if InvenTree.ready.isRunningMigrations():
|
||||
create = False
|
||||
|
||||
# Perform cache lookup by default
|
||||
do_cache = kwargs.pop('cache', True)
|
||||
do_cache = False
|
||||
|
||||
ckey = cls.create_cache_key(key, **kwargs)
|
||||
|
||||
@@ -568,7 +582,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
if cached_setting is not None:
|
||||
return cached_setting
|
||||
|
||||
except AppRegistryNotReady:
|
||||
except Exception:
|
||||
# Cache is not ready yet
|
||||
do_cache = False
|
||||
|
||||
@@ -647,7 +661,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def set_setting(cls, key, value, change_user, create=True, **kwargs):
|
||||
def set_setting(cls, key, value, change_user=None, create=True, **kwargs):
|
||||
"""Set the value of a particular setting. If it does not exist, option to create it.
|
||||
|
||||
Args:
|
||||
@@ -668,12 +682,24 @@ class BaseInvenTreeSetting(models.Model):
|
||||
}
|
||||
|
||||
try:
|
||||
setting = cls.objects.get(**filters)
|
||||
except cls.DoesNotExist:
|
||||
if create:
|
||||
setting = cls(key=key, **kwargs)
|
||||
else:
|
||||
return
|
||||
setting = cls.objects.filter(**filters).first()
|
||||
|
||||
if not setting:
|
||||
if create:
|
||||
setting = cls(key=key, **kwargs)
|
||||
else:
|
||||
return
|
||||
|
||||
except (OperationalError, ProgrammingError):
|
||||
if not key.startswith('_'):
|
||||
logger.warning("Database is locked, cannot set setting '%s'", key)
|
||||
# Likely the DB is locked - not much we can do here
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc))
|
||||
)
|
||||
return
|
||||
|
||||
# Enforce standard boolean representation
|
||||
if setting.is_bool():
|
||||
@@ -700,6 +726,10 @@ class BaseInvenTreeSetting(models.Model):
|
||||
attempts=attempts - 1,
|
||||
**kwargs,
|
||||
)
|
||||
except (OperationalError, ProgrammingError):
|
||||
logger.warning("Database is locked, cannot set setting '%s'", key)
|
||||
# Likely the DB is locked - not much we can do here
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Some other error
|
||||
logger.exception(
|
||||
@@ -1065,6 +1095,15 @@ def settings_group_options():
|
||||
|
||||
def update_instance_url(setting):
|
||||
"""Update the first site objects domain to url."""
|
||||
if not settings.SITE_MULTI:
|
||||
return
|
||||
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
except (ImportError, RuntimeError):
|
||||
# Multi-site support not enabled
|
||||
return
|
||||
|
||||
site_obj = Site.objects.all().order_by('id').first()
|
||||
site_obj.domain = setting.value
|
||||
site_obj.save()
|
||||
@@ -1072,6 +1111,15 @@ def update_instance_url(setting):
|
||||
|
||||
def update_instance_name(setting):
|
||||
"""Update the first site objects name to instance name."""
|
||||
if not settings.SITE_MULTI:
|
||||
return
|
||||
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
except (ImportError, RuntimeError):
|
||||
# Multi-site support not enabled
|
||||
return
|
||||
|
||||
site_obj = Site.objects.all().order_by('id').first()
|
||||
site_obj.name = setting.value
|
||||
site_obj.save()
|
||||
@@ -1132,6 +1180,16 @@ def reload_plugin_registry(setting):
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
|
||||
class InvenTreeSettingsKeyType(SettingsKeyType):
|
||||
"""InvenTreeSettingsKeyType has additional properties only global settings support.
|
||||
|
||||
Attributes:
|
||||
requires_restart: If True, a server restart is required after changing the setting
|
||||
"""
|
||||
|
||||
requires_restart: bool
|
||||
|
||||
|
||||
class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
"""An InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values).
|
||||
|
||||
@@ -1139,6 +1197,8 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
even if that key does not exist.
|
||||
"""
|
||||
|
||||
SETTINGS: dict[str, InvenTreeSettingsKeyType]
|
||||
|
||||
class Meta:
|
||||
"""Meta options for InvenTreeSetting."""
|
||||
|
||||
@@ -1593,6 +1653,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'REPORT_LOG_ERRORS': {
|
||||
'name': _('Log Report Errors'),
|
||||
'description': _('Log errors which occur when generating reports'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'REPORT_DEFAULT_PAGE_SIZE': {
|
||||
'name': _('Page Size'),
|
||||
'description': _('Default page size for PDF reports'),
|
||||
@@ -1684,6 +1750,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCK_ENFORCE_BOM_INSTALLATION': {
|
||||
'name': _('Check BOM when installing items'),
|
||||
'description': _(
|
||||
'Installed stock items must exist in the BOM for the parent part'
|
||||
),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'BUILDORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Build Order Reference Pattern'),
|
||||
'description': _(
|
||||
@@ -1843,6 +1917,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
'requires_restart': True,
|
||||
},
|
||||
'PLUGIN_UPDATE_CHECK': {
|
||||
'name': _('Check for plugin updates'),
|
||||
'description': _('Enable periodic checks for updates to installed plugins'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
# Settings for plugin mixin features
|
||||
'ENABLE_PLUGINS_URL': {
|
||||
'name': _('Enable URL integration'),
|
||||
@@ -1924,6 +2004,20 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
|
||||
'name': _('Block Until Tests Pass'),
|
||||
'description': _(
|
||||
'Prevent build outputs from being completed until all required tests pass'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'TEST_STATION_DATA': {
|
||||
'name': _('Enable Test Station Data'),
|
||||
'description': _('Enable test station data collection for test results'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
}
|
||||
|
||||
typ = 'inventree'
|
||||
@@ -2318,6 +2412,11 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'LAST_USED_PRINTING_MACHINES': {
|
||||
'name': _('Last used printing machines'),
|
||||
'description': _('Save the last used printing machines for a user'),
|
||||
'default': '',
|
||||
},
|
||||
}
|
||||
|
||||
typ = 'user'
|
||||
@@ -2827,12 +2926,17 @@ class NotificationMessage(models.Model):
|
||||
"""Return API endpoint."""
|
||||
return reverse('api-notifications-list')
|
||||
|
||||
def age(self):
|
||||
def age(self) -> int:
|
||||
"""Age of the message in seconds."""
|
||||
delta = now() - self.creation
|
||||
# Add timezone information if TZ is enabled (in production mode mostly)
|
||||
delta = now() - (
|
||||
self.creation.replace(tzinfo=timezone.utc)
|
||||
if settings.USE_TZ
|
||||
else self.creation
|
||||
)
|
||||
return delta.seconds
|
||||
|
||||
def age_human(self):
|
||||
def age_human(self) -> str:
|
||||
"""Humanized age."""
|
||||
return naturaltime(self.creation)
|
||||
|
||||
|
@@ -142,7 +142,7 @@ class NotificationMethod:
|
||||
return False
|
||||
|
||||
# Check if method globally enabled
|
||||
plg_instance = registry.plugins.get(plg_cls.NAME.lower())
|
||||
plg_instance = registry.get_plugin(plg_cls.NAME.lower())
|
||||
if plg_instance and not plg_instance.get_setting(self.GLOBAL_SETTING):
|
||||
return True
|
||||
|
||||
@@ -422,7 +422,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
|
||||
# Collect possible methods
|
||||
if delivery_methods is None:
|
||||
delivery_methods = storage.liste
|
||||
delivery_methods = storage.liste or []
|
||||
else:
|
||||
delivery_methods = delivery_methods - IGNORED_NOTIFICATION_CLS
|
||||
|
||||
|
@@ -59,6 +59,10 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
||||
|
||||
units = serializers.CharField(read_only=True)
|
||||
|
||||
required = serializers.BooleanField(read_only=True)
|
||||
|
||||
typ = serializers.CharField(read_only=True)
|
||||
|
||||
def get_choices(self, obj):
|
||||
"""Returns the choices available for a given item."""
|
||||
results = []
|
||||
@@ -148,6 +152,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
|
||||
'model_name',
|
||||
'api_url',
|
||||
'typ',
|
||||
'required',
|
||||
]
|
||||
|
||||
# set Meta class
|
||||
@@ -195,7 +200,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
|
||||
user = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
read = serializers.BooleanField()
|
||||
|
||||
def get_target(self, obj):
|
||||
def get_target(self, obj) -> dict:
|
||||
"""Function to resolve generic object reference to target."""
|
||||
target = get_objectreference(obj, 'target_content_type', 'target_object_id')
|
||||
|
||||
@@ -217,7 +222,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
|
||||
|
||||
return target
|
||||
|
||||
def get_source(self, obj):
|
||||
def get_source(self, obj) -> dict:
|
||||
"""Function to resolve generic object reference to source."""
|
||||
return get_objectreference(obj, 'source_content_type', 'source_object_id')
|
||||
|
||||
|
@@ -14,7 +14,10 @@ def currency_code_default():
|
||||
"""Returns the default currency code (or USD if not specified)."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
cached_value = cache.get('currency_code_default', '')
|
||||
try:
|
||||
cached_value = cache.get('currency_code_default', '')
|
||||
except Exception:
|
||||
cached_value = None
|
||||
|
||||
if cached_value:
|
||||
return cached_value
|
||||
@@ -31,7 +34,10 @@ def currency_code_default():
|
||||
code = 'USD' # pragma: no cover
|
||||
|
||||
# Cache the value for a short amount of time
|
||||
cache.set('currency_code_default', code, 30)
|
||||
try:
|
||||
cache.set('currency_code_default', code, 30)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return code
|
||||
|
||||
@@ -56,3 +62,12 @@ def stock_expiry_enabled():
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY', False, create=False)
|
||||
|
||||
|
||||
def prevent_build_output_complete_on_incompleted_tests():
|
||||
"""Returns True if the completion of the build outputs is disabled until the required tests are passed."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
return InvenTreeSetting.get_setting(
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', False, create=False
|
||||
)
|
||||
|
@@ -12,6 +12,7 @@ from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
import PIL
|
||||
@@ -271,6 +272,7 @@ class SettingsTest(InvenTreeTestCase):
|
||||
print(f"run_settings_check failed for user setting '{key}'")
|
||||
raise exc
|
||||
|
||||
@override_settings(SITE_URL=None)
|
||||
def test_defaults(self):
|
||||
"""Populate the settings with default values."""
|
||||
for key in InvenTreeSetting.SETTINGS.keys():
|
||||
@@ -658,6 +660,30 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
|
||||
...
|
||||
|
||||
|
||||
class ErrorReportTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the error report API."""
|
||||
|
||||
def test_error_list(self):
|
||||
"""Test error list."""
|
||||
from InvenTree.exceptions import log_error
|
||||
|
||||
url = reverse('api-error-list')
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
# Throw an error!
|
||||
log_error(
|
||||
'test error', error_name='My custom error', error_info={'test': 'data'}
|
||||
)
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
err = response.data[0]
|
||||
for k in ['when', 'info', 'data', 'path']:
|
||||
self.assertIn(k, err)
|
||||
|
||||
|
||||
class TaskListApiTests(InvenTreeAPITestCase):
|
||||
"""Unit tests for the background task API endpoints."""
|
||||
|
||||
|
@@ -33,6 +33,7 @@ class CompanyResource(InvenTreeResource):
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
@admin.register(Company)
|
||||
class CompanyAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the Company model."""
|
||||
|
||||
@@ -69,6 +70,7 @@ class SupplierPriceBreakInline(admin.TabularInline):
|
||||
model = SupplierPriceBreak
|
||||
|
||||
|
||||
@admin.register(SupplierPart)
|
||||
class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SupplierPart model."""
|
||||
|
||||
@@ -105,6 +107,7 @@ class ManufacturerPartResource(InvenTreeResource):
|
||||
manufacturer_name = Field(attribute='manufacturer__name', readonly=True)
|
||||
|
||||
|
||||
@admin.register(ManufacturerPart)
|
||||
class ManufacturerPartAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for ManufacturerPart model."""
|
||||
|
||||
@@ -117,6 +120,7 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('part', 'manufacturer')
|
||||
|
||||
|
||||
@admin.register(ManufacturerPartAttachment)
|
||||
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for ManufacturerPartAttachment model."""
|
||||
|
||||
@@ -137,6 +141,7 @@ class ManufacturerPartParameterResource(InvenTreeResource):
|
||||
clean_model_instance = True
|
||||
|
||||
|
||||
@admin.register(ManufacturerPartParameter)
|
||||
class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for ManufacturerPartParameter model."""
|
||||
|
||||
@@ -173,6 +178,7 @@ class SupplierPriceBreakResource(InvenTreeResource):
|
||||
MPN = Field(attribute='part__MPN', readonly=True)
|
||||
|
||||
|
||||
@admin.register(SupplierPriceBreak)
|
||||
class SupplierPriceBreakAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SupplierPriceBreak model."""
|
||||
|
||||
@@ -197,6 +203,7 @@ class AddressResource(InvenTreeResource):
|
||||
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
|
||||
|
||||
|
||||
@admin.register(Address)
|
||||
class AddressAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the Address model."""
|
||||
|
||||
@@ -221,6 +228,7 @@ class ContactResource(InvenTreeResource):
|
||||
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
|
||||
|
||||
|
||||
@admin.register(Contact)
|
||||
class ContactAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the Contact model."""
|
||||
|
||||
@@ -229,15 +237,3 @@ class ContactAdmin(ImportExportModelAdmin):
|
||||
list_display = ('company', 'name', 'role', 'email', 'phone')
|
||||
|
||||
search_fields = ['company', 'name', 'email']
|
||||
|
||||
|
||||
admin.site.register(Company, CompanyAdmin)
|
||||
admin.site.register(SupplierPart, SupplierPartAdmin)
|
||||
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
||||
|
||||
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
||||
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
|
||||
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
|
||||
|
||||
admin.site.register(Address, AddressAdmin)
|
||||
admin.site.register(Contact, ContactAdmin)
|
||||
|
@@ -5,6 +5,7 @@ from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
@@ -24,17 +25,12 @@ import common.settings
|
||||
import InvenTree.conversion
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.models
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import InvenTree.validators
|
||||
from common.settings import currency_code_default
|
||||
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
|
||||
from InvenTree.models import (
|
||||
InvenTreeAttachment,
|
||||
InvenTreeBarcodeMixin,
|
||||
InvenTreeNotesMixin,
|
||||
MetadataMixin,
|
||||
)
|
||||
from InvenTree.status_codes import PurchaseOrderStatusGroups
|
||||
|
||||
|
||||
@@ -63,7 +59,9 @@ def rename_company_image(instance, filename):
|
||||
return os.path.join(base, fn)
|
||||
|
||||
|
||||
class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
|
||||
class Company(
|
||||
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel
|
||||
):
|
||||
"""A Company object represents an external company.
|
||||
|
||||
It may be a supplier or a customer or a manufacturer (or a combination)
|
||||
@@ -219,7 +217,9 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Get the web URL for the detail view for this Company."""
|
||||
return reverse('company-detail', kwargs={'pk': self.id})
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
return reverse('company-detail', kwargs={'pk': self.id})
|
||||
return InvenTree.helpers.pui_url(f'/company/{self.id}')
|
||||
|
||||
def get_image_url(self):
|
||||
"""Return the URL of the image for this company."""
|
||||
@@ -250,7 +250,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
|
||||
).distinct()
|
||||
|
||||
|
||||
class CompanyAttachment(InvenTreeAttachment):
|
||||
class CompanyAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file or URL attachments against a Company object."""
|
||||
|
||||
@staticmethod
|
||||
@@ -270,7 +270,7 @@ class CompanyAttachment(InvenTreeAttachment):
|
||||
)
|
||||
|
||||
|
||||
class Contact(MetadataMixin, models.Model):
|
||||
class Contact(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
|
||||
|
||||
Attributes:
|
||||
@@ -299,7 +299,7 @@ class Contact(MetadataMixin, models.Model):
|
||||
role = models.CharField(max_length=100, blank=True)
|
||||
|
||||
|
||||
class Address(models.Model):
|
||||
class Address(InvenTree.models.InvenTreeModel):
|
||||
"""An address represents a physical location where the company is located. It is possible for a company to have multiple locations.
|
||||
|
||||
Attributes:
|
||||
@@ -454,7 +454,9 @@ class Address(models.Model):
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
|
||||
class ManufacturerPart(
|
||||
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel
|
||||
):
|
||||
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
|
||||
|
||||
Attributes:
|
||||
@@ -555,7 +557,7 @@ class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
|
||||
return s
|
||||
|
||||
|
||||
class ManufacturerPartAttachment(InvenTreeAttachment):
|
||||
class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a ManufacturerPart object."""
|
||||
|
||||
@staticmethod
|
||||
@@ -575,7 +577,7 @@ class ManufacturerPartAttachment(InvenTreeAttachment):
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPartParameter(models.Model):
|
||||
class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
|
||||
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
|
||||
|
||||
This is used to represent parameters / properties for a particular manufacturer part.
|
||||
@@ -640,7 +642,12 @@ class SupplierPartManager(models.Manager):
|
||||
)
|
||||
|
||||
|
||||
class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin):
|
||||
class SupplierPart(
|
||||
InvenTree.models.MetadataMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
common.models.MetaMixin,
|
||||
InvenTree.models.InvenTreeModel,
|
||||
):
|
||||
"""Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers.
|
||||
|
||||
Attributes:
|
||||
@@ -679,7 +686,9 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Return the web URL of the detail view for this SupplierPart."""
|
||||
return reverse('supplier-part-detail', kwargs={'pk': self.id})
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
return reverse('supplier-part-detail', kwargs={'pk': self.id})
|
||||
return InvenTree.helpers.pui_url(f'/purchasing/supplier-part/{self.id}')
|
||||
|
||||
def api_instance_filters(self):
|
||||
"""Return custom API filters for this particular instance."""
|
||||
@@ -895,6 +904,11 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
|
||||
self.availability_updated = datetime.now()
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return string representation of own name."""
|
||||
return str(self)
|
||||
|
||||
@property
|
||||
def manufacturer_string(self):
|
||||
"""Format a MPN string for this SupplierPart.
|
||||
|
@@ -42,11 +42,13 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = Company
|
||||
fields = ['pk', 'url', 'name', 'description', 'image']
|
||||
fields = ['pk', 'url', 'name', 'description', 'image', 'thumbnail']
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
image = InvenTreeImageSerializerField(read_only=True)
|
||||
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
||||
|
||||
class AddressSerializer(InvenTreeModelSerializer):
|
||||
@@ -309,6 +311,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
'manufacturer_part',
|
||||
'manufacturer_part_detail',
|
||||
'MPN',
|
||||
'name',
|
||||
'note',
|
||||
'pk',
|
||||
'barcode_hash',
|
||||
@@ -395,6 +398,8 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
source='manufacturer_part', part_detail=False, read_only=True
|
||||
)
|
||||
|
||||
name = serializers.CharField(read_only=True)
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
# Date fields
|
||||
|
@@ -1,10 +1,12 @@
|
||||
"""Unit tests for Company views (see views.py)."""
|
||||
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
|
||||
@tag('cui')
|
||||
class CompanyViewTest(InvenTreeTestCase):
|
||||
"""Tests for various 'Company' views."""
|
||||
|
||||
|
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
@@ -59,7 +60,8 @@ class CompanySimpleTest(TestCase):
|
||||
def test_company_url(self):
|
||||
"""Test the detail URL for a company."""
|
||||
c = Company.objects.get(pk=1)
|
||||
self.assertEqual(c.get_absolute_url(), '/company/1/')
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
self.assertEqual(c.get_absolute_url(), '/company/1/')
|
||||
|
||||
def test_image_renamer(self):
|
||||
"""Test the company image upload functionality."""
|
||||
|
@@ -66,12 +66,6 @@ debug: True
|
||||
# Or, use the environment variable INVENTREE_ADMIN_URL
|
||||
#admin_url: 'admin'
|
||||
|
||||
# Set enabled frontends
|
||||
# Use the environment variable INVENTREE_CLASSIC_FRONTEND
|
||||
# classic_frontend: True
|
||||
# Use the environment variable INVENTREE_PLATFORM_FRONTEND
|
||||
# platform_frontend: True
|
||||
|
||||
# Configure the system logging level
|
||||
# Use environment variable INVENTREE_LOG_LEVEL
|
||||
# Options: DEBUG / INFO / WARNING / ERROR / CRITICAL
|
||||
@@ -90,8 +84,8 @@ language: en-us
|
||||
timezone: UTC
|
||||
|
||||
# Base URL for the InvenTree server
|
||||
# Use the environment variable INVENTREE_BASE_URL
|
||||
# base_url: 'http://localhost:8000'
|
||||
# Use the environment variable INVENTREE_SITE_URL
|
||||
# site_url: 'http://localhost:8000'
|
||||
|
||||
# Base currency code (or use env var INVENTREE_BASE_CURRENCY)
|
||||
base_currency: USD
|
||||
@@ -158,6 +152,7 @@ sentry_enabled: False
|
||||
# Set this variable to True to enable InvenTree Plugins
|
||||
# Alternatively, use the environment variable INVENTREE_PLUGINS_ENABLED
|
||||
plugins_enabled: False
|
||||
#plugin_noinstall: True
|
||||
#plugin_file: '/path/to/plugins.txt'
|
||||
#plugin_dir: '/path/to/plugins/'
|
||||
|
||||
@@ -171,28 +166,40 @@ auto_update: False
|
||||
allowed_hosts:
|
||||
- '*'
|
||||
|
||||
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/ottoyiu/django-cors-headers)
|
||||
# Following parameters are
|
||||
cors:
|
||||
# CORS_ORIGIN_ALLOW_ALL - If True, the whitelist will not be used and all origins will be accepted.
|
||||
allow_all: True
|
||||
# Trusted origins (see CSRF_TRUSTED_ORIGINS in Django settings documentation)
|
||||
# If you are running behind a proxy, you may need to add the proxy address here
|
||||
# trusted_origins:
|
||||
# - 'http://localhost'
|
||||
# - 'http://*.localhost'
|
||||
|
||||
# Proxy forwarding settings
|
||||
# If InvenTree is running behind a proxy, you may need to configure these settings
|
||||
|
||||
# Override with the environment variable INVENTREE_USE_X_FORWARDED_HOST
|
||||
use_x_forwarded_host: false
|
||||
|
||||
# Override with the environment variable INVENTREE_USE_X_FORWARDED_PORT
|
||||
use_x_forwarded_port: false
|
||||
|
||||
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/adamchainz/django-cors-headers)
|
||||
cors:
|
||||
allow_all: true
|
||||
allow_credentials: true
|
||||
|
||||
# CORS_ORIGIN_WHITELIST - A list of origins that are authorized to make cross-site HTTP requests. Defaults to []
|
||||
# whitelist:
|
||||
# - https://example.com
|
||||
# - https://sub.example.com
|
||||
|
||||
# regex:
|
||||
|
||||
# MEDIA_ROOT is the local filesystem location for storing uploaded files
|
||||
#media_root: '/home/inventree/data/media'
|
||||
|
||||
# STATIC_ROOT is the local filesystem location for storing static files
|
||||
#static_root: '/home/inventree/data/static'
|
||||
|
||||
### Backup configuration options ###
|
||||
# INVENTREE_BACKUP_DIR is the local filesystem location for storing backups
|
||||
backup_storage: django.core.files.storage.FileSystemStorage
|
||||
#backup_dir: '/home/inventree/data/backup'
|
||||
#backup_options:
|
||||
|
||||
# Background worker options
|
||||
background:
|
||||
@@ -200,14 +207,6 @@ background:
|
||||
timeout: 90
|
||||
max_attempts: 5
|
||||
|
||||
# Optional URL schemes to allow in URL fields
|
||||
# By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps']
|
||||
# Uncomment the lines below to allow extra schemes
|
||||
#extra_url_schemes:
|
||||
# - mailto
|
||||
# - git
|
||||
# - ssh
|
||||
|
||||
# Login configuration
|
||||
login_confirm_days: 3
|
||||
login_attempts: 5
|
||||
@@ -215,7 +214,7 @@ login_default_protocol: http
|
||||
|
||||
# Remote / proxy login
|
||||
# These settings can introduce security problems if configured incorrectly. Please read
|
||||
# https://docs.djangoproject.com/en/4.0/howto/auth-remote-user/ for more details
|
||||
# https://docs.djangoproject.com/en/4.2/howto/auth-remote-user/ for more details
|
||||
# The header name should be prefixed by `HTTP`. Please read the docs for more details
|
||||
# https://docs.djangoproject.com/en/stable/ref/request-response/#django.http.HttpRequest.META
|
||||
remote_login_enabled: False
|
||||
@@ -232,23 +231,6 @@ remote_login_header: HTTP_REMOTE_USER
|
||||
# https://docs.djangoproject.com/en/stable/ref/settings/#logout-redirect-url
|
||||
#logout_redirect_url: 'index'
|
||||
|
||||
# Permit custom authentication backends
|
||||
#authentication_backends:
|
||||
# - 'django.contrib.auth.backends.ModelBackend'
|
||||
|
||||
# Custom middleware, sometimes needed alongside an authentication backend change.
|
||||
#middleware:
|
||||
# - 'django.middleware.security.SecurityMiddleware'
|
||||
# - 'django.contrib.sessions.middleware.SessionMiddleware'
|
||||
# - 'django.middleware.locale.LocaleMiddleware'
|
||||
# - 'django.middleware.common.CommonMiddleware'
|
||||
# - 'django.middleware.csrf.CsrfViewMiddleware'
|
||||
# - 'corsheaders.middleware.CorsMiddleware'
|
||||
# - 'django.contrib.auth.middleware.AuthenticationMiddleware'
|
||||
# - 'django.contrib.messages.middleware.MessageMiddleware'
|
||||
# - 'django.middleware.clickjacking.XFrameOptionsMiddleware'
|
||||
# - 'InvenTree.middleware.AuthRequiredMiddleware'
|
||||
|
||||
# Add SSO login-backends (see examples below)
|
||||
# social_backends:
|
||||
# - 'allauth.socialaccount.providers.google'
|
||||
@@ -319,22 +301,8 @@ remote_login_header: HTTP_REMOTE_USER
|
||||
# logo: img/custom_logo.png
|
||||
# splash: img/custom_splash.jpg
|
||||
|
||||
# Frontend UI settings
|
||||
# frontend_settings:
|
||||
# base_url: 'frontend'
|
||||
# server_list:
|
||||
# my_server1:
|
||||
# host: https://demo.inventree.org/
|
||||
# name: InvenTree Demo
|
||||
# default_server: my_server1
|
||||
# show_server_selector: false
|
||||
# sentry_dsn: https://84f0c3ea90c64e5092e2bf5dfe325725@o1047628.ingest.sentry.io/4504160008273920
|
||||
# environment: development
|
||||
|
||||
# Custom flags
|
||||
# InvenTree uses django-flags; read more in their docs at https://cfpb.github.io/django-flags/conditions/
|
||||
# Use environment variable INVENTREE_FLAGS or the settings below
|
||||
# flags:
|
||||
# MY_FLAG:
|
||||
# - condition: 'parameter'
|
||||
# value: 'my_flag_param1'
|
||||
# Set enabled frontends
|
||||
# Use the environment variable INVENTREE_CLASSIC_FRONTEND
|
||||
# classic_frontend: True
|
||||
# Use the environment variable INVENTREE_PLATFORM_FRONTEND
|
||||
# platform_frontend: True
|
||||
|
@@ -2,15 +2,24 @@
|
||||
|
||||
import inspect
|
||||
|
||||
from rest_framework import permissions
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from InvenTree.serializers import EmptySerializer
|
||||
|
||||
from .states import StatusCode
|
||||
|
||||
|
||||
class StatusView(APIView):
|
||||
class StatusViewSerializer(serializers.Serializer):
|
||||
"""Serializer for the StatusView responses."""
|
||||
|
||||
class_name = serializers.CharField()
|
||||
values = serializers.DictField()
|
||||
|
||||
|
||||
class StatusView(GenericAPIView):
|
||||
"""Generic API endpoint for discovering information on 'status codes' for a particular model.
|
||||
|
||||
This class should be implemented as a subclass for each type of status.
|
||||
@@ -28,12 +37,19 @@ class StatusView(APIView):
|
||||
status_model = self.kwargs.get(self.MODEL_REF, None)
|
||||
|
||||
if status_model is None:
|
||||
raise ValidationError(
|
||||
raise serializers.ValidationError(
|
||||
f"StatusView view called without '{self.MODEL_REF}' parameter"
|
||||
)
|
||||
|
||||
return status_model
|
||||
|
||||
@extend_schema(
|
||||
description='Retrieve information about a specific status code',
|
||||
responses={
|
||||
200: OpenApiResponse(description='Status code information'),
|
||||
400: OpenApiResponse(description='Invalid request'),
|
||||
},
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform a GET request to learn information about status codes."""
|
||||
status_class = self.get_status_model()
|
||||
@@ -53,15 +69,22 @@ class AllStatusViews(StatusView):
|
||||
"""Endpoint for listing all defined status models."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = EmptySerializer
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform a GET request to learn information about status codes."""
|
||||
data = {}
|
||||
|
||||
for status_class in StatusCode.__subclasses__():
|
||||
data[status_class.__name__] = {
|
||||
'class': status_class.__name__,
|
||||
'values': status_class.dict(),
|
||||
}
|
||||
def discover_status_codes(parent_status_class, prefix=None):
|
||||
"""Recursively discover status classes."""
|
||||
for status_class in parent_status_class.__subclasses__():
|
||||
name = '__'.join([*(prefix or []), status_class.__name__])
|
||||
data[name] = {
|
||||
'class': status_class.__name__,
|
||||
'values': status_class.dict(),
|
||||
}
|
||||
discover_status_codes(status_class, [name])
|
||||
|
||||
discover_status_codes(StatusCode)
|
||||
|
||||
return Response(data)
|
||||
|
0
InvenTree/generic/templating/__init__.py
Normal file
0
InvenTree/generic/templating/__init__.py
Normal file
140
InvenTree/generic/templating/apps.py
Normal file
140
InvenTree/generic/templating/apps.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Shared templating code."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.config import ensure_dir
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
MEDIA_STORAGE_DIR = Path(settings.MEDIA_ROOT)
|
||||
|
||||
|
||||
class TemplatingMixin:
|
||||
"""Mixin that contains shared templating code."""
|
||||
|
||||
name: str = ''
|
||||
db: str = ''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Ensure that the required properties are set."""
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.name == '':
|
||||
raise NotImplementedError('ref must be set')
|
||||
if self.db == '':
|
||||
raise NotImplementedError('db must be set')
|
||||
|
||||
def create_defaults(self):
|
||||
"""Function that creates all default templates for the app."""
|
||||
raise NotImplementedError('create_defaults must be implemented')
|
||||
|
||||
def get_src_dir(self, ref_name):
|
||||
"""Get the source directory for the default templates."""
|
||||
raise NotImplementedError('get_src_dir must be implemented')
|
||||
|
||||
def get_new_obj_data(self, data, filename):
|
||||
"""Get the data for a new template db object."""
|
||||
raise NotImplementedError('get_new_obj_data must be implemented')
|
||||
|
||||
# Standardized code
|
||||
def ready(self):
|
||||
"""This function is called whenever the app is loaded."""
|
||||
import InvenTree.ready
|
||||
|
||||
# skip loading if plugin registry is not loaded or we run in a background thread
|
||||
if (
|
||||
not InvenTree.ready.isPluginRegistryLoaded()
|
||||
or not InvenTree.ready.isInMainThread()
|
||||
):
|
||||
return
|
||||
|
||||
if not InvenTree.ready.canAppAccessDatabase(allow_test=False):
|
||||
return # pragma: no cover
|
||||
|
||||
with maintenance_mode_on():
|
||||
try:
|
||||
self.create_defaults()
|
||||
except (
|
||||
AppRegistryNotReady,
|
||||
IntegrityError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
):
|
||||
# Database might not yet be ready
|
||||
warnings.warn(
|
||||
f'Database was not ready for creating {self.name}s', stacklevel=2
|
||||
)
|
||||
|
||||
set_maintenance_mode(False)
|
||||
|
||||
def create_template_dir(self, model, data):
|
||||
"""Create folder and database entries for the default templates, if they do not already exist."""
|
||||
ref_name = model.getSubdir()
|
||||
|
||||
# Create root dir for templates
|
||||
src_dir = self.get_src_dir(ref_name)
|
||||
dst_dir = MEDIA_STORAGE_DIR.joinpath(self.name, 'inventree', ref_name)
|
||||
ensure_dir(dst_dir, default_storage)
|
||||
|
||||
# Copy each template across (if required)
|
||||
for entry in data:
|
||||
self.create_template_file(model, src_dir, entry, ref_name)
|
||||
|
||||
def create_template_file(self, model, src_dir, data, ref_name):
|
||||
"""Ensure a label template is in place."""
|
||||
# Destination filename
|
||||
filename = os.path.join(self.name, 'inventree', ref_name, data['file'])
|
||||
|
||||
src_file = src_dir.joinpath(data['file'])
|
||||
dst_file = MEDIA_STORAGE_DIR.joinpath(filename)
|
||||
|
||||
do_copy = False
|
||||
|
||||
if not dst_file.exists():
|
||||
logger.info("%s template '%s' is not present", self.name, filename)
|
||||
do_copy = True
|
||||
else:
|
||||
# Check if the file contents are different
|
||||
src_hash = InvenTree.helpers.hash_file(src_file)
|
||||
dst_hash = InvenTree.helpers.hash_file(dst_file)
|
||||
|
||||
if src_hash != dst_hash:
|
||||
logger.info("Hash differs for '%s'", filename)
|
||||
do_copy = True
|
||||
|
||||
if do_copy:
|
||||
logger.info("Copying %s template '%s'", self.name, dst_file)
|
||||
# Ensure destination dir exists
|
||||
dst_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy file
|
||||
default_storage.save(filename, src_file.open('rb'))
|
||||
|
||||
# Check if a file matching the template already exists
|
||||
try:
|
||||
if model.objects.filter(**{self.db: filename}).exists():
|
||||
return # pragma: no cover
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to query %s for '%s' - you should run 'invoke update' first!",
|
||||
self.name,
|
||||
filename,
|
||||
)
|
||||
|
||||
logger.info("Creating entry for %s '%s'", model, data.get('name'))
|
||||
|
||||
try:
|
||||
model.objects.create(**self.get_new_obj_data(data, filename))
|
||||
except Exception:
|
||||
logger.warning("Failed to create %s '%s'", self.name, data['name'])
|
@@ -4,6 +4,7 @@ from django.core.exceptions import FieldError, ValidationError
|
||||
from django.http import JsonResponse
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.cache import cache_page, never_cache
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
@@ -13,6 +14,7 @@ from rest_framework.request import clone_request
|
||||
|
||||
import build.models
|
||||
import common.models
|
||||
import InvenTree.exceptions
|
||||
import InvenTree.helpers
|
||||
import label.models
|
||||
import label.serializers
|
||||
@@ -232,9 +234,17 @@ class LabelPrintMixin(LabelFilterMixin):
|
||||
# At this point, we offload the label(s) to the selected plugin.
|
||||
# The plugin is responsible for handling the request and returning a response.
|
||||
|
||||
result = plugin.print_labels(
|
||||
label, items_to_print, request, printing_options=request.data
|
||||
)
|
||||
try:
|
||||
result = plugin.print_labels(
|
||||
label,
|
||||
items_to_print,
|
||||
request,
|
||||
printing_options=(serializer.data if serializer else {}),
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise (e)
|
||||
except Exception as e:
|
||||
raise ValidationError([_('Error printing label'), str(e)])
|
||||
|
||||
if isinstance(result, JsonResponse):
|
||||
result['plugin'] = plugin.plugin_slug()
|
||||
|
@@ -1,68 +1,31 @@
|
||||
"""label app specification."""
|
||||
"""Config options for the label app."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
from generic.templating.apps import TemplatingMixin
|
||||
|
||||
|
||||
class LabelConfig(AppConfig):
|
||||
"""App configuration class for the 'label' app."""
|
||||
class LabelConfig(TemplatingMixin, AppConfig):
|
||||
"""Configuration class for the "label" app."""
|
||||
|
||||
name = 'label'
|
||||
db = 'label'
|
||||
|
||||
def ready(self):
|
||||
"""This function is called whenever the label app is loaded."""
|
||||
# skip loading if plugin registry is not loaded or we run in a background thread
|
||||
if (
|
||||
not InvenTree.ready.isPluginRegistryLoaded()
|
||||
or not InvenTree.ready.isInMainThread()
|
||||
):
|
||||
return
|
||||
|
||||
if InvenTree.ready.isRunningMigrations():
|
||||
return
|
||||
|
||||
if (
|
||||
InvenTree.ready.canAppAccessDatabase(allow_test=False)
|
||||
and not InvenTree.ready.isImportingData()
|
||||
):
|
||||
try:
|
||||
self.create_labels() # pragma: no cover
|
||||
except (
|
||||
AppRegistryNotReady,
|
||||
IntegrityError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
):
|
||||
# Database might not yet be ready
|
||||
warnings.warn(
|
||||
'Database was not ready for creating labels', stacklevel=2
|
||||
)
|
||||
|
||||
def create_labels(self):
|
||||
def create_defaults(self):
|
||||
"""Create all default templates."""
|
||||
# Test if models are ready
|
||||
import label.models
|
||||
|
||||
try:
|
||||
import label.models
|
||||
except Exception: # pragma: no cover
|
||||
# Database is not ready yet
|
||||
return
|
||||
assert bool(label.models.StockLocationLabel is not None)
|
||||
|
||||
# Create the categories
|
||||
self.create_labels_category(
|
||||
self.create_template_dir(
|
||||
label.models.StockItemLabel,
|
||||
'stockitem',
|
||||
[
|
||||
{
|
||||
'file': 'qr.html',
|
||||
@@ -74,9 +37,8 @@ class LabelConfig(AppConfig):
|
||||
],
|
||||
)
|
||||
|
||||
self.create_labels_category(
|
||||
self.create_template_dir(
|
||||
label.models.StockLocationLabel,
|
||||
'stocklocation',
|
||||
[
|
||||
{
|
||||
'file': 'qr.html',
|
||||
@@ -95,9 +57,8 @@ class LabelConfig(AppConfig):
|
||||
],
|
||||
)
|
||||
|
||||
self.create_labels_category(
|
||||
self.create_template_dir(
|
||||
label.models.PartLabel,
|
||||
'part',
|
||||
[
|
||||
{
|
||||
'file': 'part_label.html',
|
||||
@@ -116,9 +77,8 @@ class LabelConfig(AppConfig):
|
||||
],
|
||||
)
|
||||
|
||||
self.create_labels_category(
|
||||
self.create_template_dir(
|
||||
label.models.BuildLineLabel,
|
||||
'buildline',
|
||||
[
|
||||
{
|
||||
'file': 'buildline_label.html',
|
||||
@@ -130,72 +90,18 @@ class LabelConfig(AppConfig):
|
||||
],
|
||||
)
|
||||
|
||||
def create_labels_category(self, model, ref_name, labels):
|
||||
"""Create folder and database entries for the default templates, if they do not already exist."""
|
||||
# Create root dir for templates
|
||||
src_dir = Path(__file__).parent.joinpath('templates', 'label', ref_name)
|
||||
def get_src_dir(self, ref_name):
|
||||
"""Get the source directory."""
|
||||
return Path(__file__).parent.joinpath('templates', self.name, ref_name)
|
||||
|
||||
dst_dir = settings.MEDIA_ROOT.joinpath('label', 'inventree', ref_name)
|
||||
|
||||
if not dst_dir.exists():
|
||||
logger.info("Creating required directory: '%s'", dst_dir)
|
||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create labels
|
||||
for label in labels:
|
||||
self.create_template_label(model, src_dir, ref_name, label)
|
||||
|
||||
def create_template_label(self, model, src_dir, ref_name, label):
|
||||
"""Ensure a label template is in place."""
|
||||
filename = os.path.join('label', 'inventree', ref_name, label['file'])
|
||||
|
||||
src_file = src_dir.joinpath(label['file'])
|
||||
dst_file = settings.MEDIA_ROOT.joinpath(filename)
|
||||
|
||||
to_copy = False
|
||||
|
||||
if dst_file.exists():
|
||||
# File already exists - let's see if it is the "same"
|
||||
|
||||
if InvenTree.helpers.hash_file(dst_file) != InvenTree.helpers.hash_file(
|
||||
src_file
|
||||
): # pragma: no cover
|
||||
logger.info("Hash differs for '%s'", filename)
|
||||
to_copy = True
|
||||
|
||||
else:
|
||||
logger.info("Label template '%s' is not present", filename)
|
||||
to_copy = True
|
||||
|
||||
if to_copy:
|
||||
logger.info("Copying label template '%s'", dst_file)
|
||||
# Ensure destination dir exists
|
||||
dst_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy file
|
||||
shutil.copyfile(src_file, dst_file)
|
||||
|
||||
# Check if a label matching the template already exists
|
||||
try:
|
||||
if model.objects.filter(label=filename).exists():
|
||||
return # pragma: no cover
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to query label for '%s' - you should run 'invoke update' first!",
|
||||
filename,
|
||||
)
|
||||
|
||||
logger.info("Creating entry for %s '%s'", model, label['name'])
|
||||
|
||||
try:
|
||||
model.objects.create(
|
||||
name=label['name'],
|
||||
description=label['description'],
|
||||
label=filename,
|
||||
filters='',
|
||||
enabled=True,
|
||||
width=label['width'],
|
||||
height=label['height'],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to create label '%s'", label['name'])
|
||||
def get_new_obj_data(self, data, filename):
|
||||
"""Get the data for a new template db object."""
|
||||
return {
|
||||
'name': data['name'],
|
||||
'description': data['description'],
|
||||
'label': filename,
|
||||
'filters': '',
|
||||
'enabled': True,
|
||||
'width': data['width'],
|
||||
'height': data['height'],
|
||||
}
|
||||
|
@@ -15,11 +15,11 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import build.models
|
||||
import InvenTree.models
|
||||
import part.models
|
||||
import stock.models
|
||||
from InvenTree.helpers import normalize, validateFilterString
|
||||
from InvenTree.helpers_model import get_base_url
|
||||
from InvenTree.models import MetadataMixin
|
||||
from plugin.registry import registry
|
||||
|
||||
try:
|
||||
@@ -88,7 +88,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
|
||||
self.pdf_filename = kwargs.get('filename', 'label.pdf')
|
||||
|
||||
|
||||
class LabelTemplate(MetadataMixin, models.Model):
|
||||
class LabelTemplate(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""Base class for generic, filterable labels."""
|
||||
|
||||
class Meta:
|
||||
@@ -96,8 +96,13 @@ class LabelTemplate(MetadataMixin, models.Model):
|
||||
|
||||
abstract = True
|
||||
|
||||
@classmethod
|
||||
def getSubdir(cls) -> str:
|
||||
"""Return the subdirectory for this label."""
|
||||
return cls.SUBDIR
|
||||
|
||||
# Each class of label files will be stored in a separate subdirectory
|
||||
SUBDIR = 'label'
|
||||
SUBDIR: str = 'label'
|
||||
|
||||
# Object we will be printing against (will be filled out later)
|
||||
object_to_print = None
|
||||
|
@@ -15,7 +15,16 @@ class LabelSerializerBase(InvenTreeModelSerializer):
|
||||
@staticmethod
|
||||
def label_fields():
|
||||
"""Generic serializer fields for a label template."""
|
||||
return ['pk', 'name', 'description', 'label', 'filters', 'enabled']
|
||||
return [
|
||||
'pk',
|
||||
'name',
|
||||
'description',
|
||||
'label',
|
||||
'filters',
|
||||
'width',
|
||||
'height',
|
||||
'enabled',
|
||||
]
|
||||
|
||||
|
||||
class StockItemLabelSerializer(LabelSerializerBase):
|
||||
|
@@ -30,7 +30,7 @@ class LabelTest(InvenTreeAPITestCase):
|
||||
def setUpTestData(cls):
|
||||
"""Ensure that some label instances exist as part of init routine."""
|
||||
super().setUpTestData()
|
||||
apps.get_app_config('label').create_labels()
|
||||
apps.get_app_config('label').create_defaults()
|
||||
|
||||
def test_default_labels(self):
|
||||
"""Test that the default label templates are copied across."""
|
||||
@@ -142,7 +142,8 @@ class LabelTest(InvenTreeAPITestCase):
|
||||
# Test that each element has been rendered correctly
|
||||
self.assertIn(f'part: {part_pk} - {part_name}', content)
|
||||
self.assertIn(f'data: {{"part": {part_pk}}}', content)
|
||||
self.assertIn(f'http://testserver/part/{part_pk}/', content)
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
self.assertIn(f'http://testserver/part/{part_pk}/', content)
|
||||
|
||||
# Check that a encoded image has been generated
|
||||
self.assertIn('data:image/png;charset=utf-8;base64,', content)
|
||||
|
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user