mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-23 01:25:45 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.241.1/containers/python-3/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
|
||||
ARG VARIANT="3.10-bullseye"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||
ARG NODE_VERSION="none"
|
||||
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
|
||||
# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image.
|
||||
# COPY requirements.txt /tmp/pip-tmp/
|
||||
# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \
|
||||
# && rm -rf /tmp/pip-tmp
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \
|
||||
apt-get -y install --no-install-recommends \
|
||||
git gcc g++ gettext gnupg libffi-dev \
|
||||
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
|
||||
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
|
||||
# Image format support
|
||||
libjpeg-dev webp \
|
||||
# SQLite support
|
||||
sqlite3 \
|
||||
# PostgreSQL support
|
||||
libpq-dev \
|
||||
# MySQL / MariaDB support
|
||||
default-libmysqlclient-dev mariadb-client && \
|
||||
apt-get autoclean && apt-get autoremove
|
||||
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
|
||||
# Update pip
|
||||
RUN pip install --upgrade pip
|
||||
|
||||
# Install required base-level python packages
|
||||
COPY ./docker/requirements.txt base_requirements.txt
|
||||
RUN pip install --disable-pip-version-check -U -r base_requirements.txt
|
||||
|
||||
# preserve command history between container starts
|
||||
# Ref: https://code.visualstudio.com/remote/advancedcontainers/persist-bash-history
|
||||
# Folder will be created in 'postCreateCommand' in devcontainer.json as it's not preserved due to the bind mount
|
||||
RUN echo "export PROMPT_COMMAND='history -a' && export HISTFILE=/workspaces/InvenTree/dev/commandhistory/.bash_history" >> "/home/vscode/.bashrc"
|
||||
|
||||
WORKDIR /workspaces/InvenTree
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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": "..",
|
||||
"args": {
|
||||
// Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||
"VARIANT": "3.10-bullseye",
|
||||
// Options
|
||||
"NODE_VERSION": "lts/*"
|
||||
}
|
||||
},
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/workspaces/InvenTree/dev/venv/bin/python",
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.pylintEnabled": false,
|
||||
"python.linting.flake8Enabled": true,
|
||||
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
|
||||
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
|
||||
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
|
||||
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
|
||||
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
|
||||
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
|
||||
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
|
||||
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint"
|
||||
},
|
||||
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"batisteo.vscode-django"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [8000],
|
||||
"portsAttributes": {
|
||||
"8000": {
|
||||
"label": "InvenTree server"
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "./.devcontainer/postCreateCommand.sh",
|
||||
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "vscode",
|
||||
"features": {
|
||||
"git": "os-provided",
|
||||
"github-cli": "latest"
|
||||
},
|
||||
|
||||
"remoteEnv": {
|
||||
// Inventree config
|
||||
"INVENTREE_DEBUG": "True",
|
||||
"INVENTREE_DEBUG_LEVEL": "INFO",
|
||||
"INVENTREE_DB_ENGINE": "sqlite3",
|
||||
"INVENTREE_DB_NAME": "/workspaces/InvenTree/dev/database.sqlite3",
|
||||
"INVENTREE_MEDIA_ROOT": "/workspaces/InvenTree/dev/media",
|
||||
"INVENTREE_STATIC_ROOT": "/workspaces/InvenTree/dev/static",
|
||||
"INVENTREE_CONFIG_FILE": "/workspaces/InvenTree/dev/config.yaml",
|
||||
"INVENTREE_SECRET_KEY_FILE": "/workspaces/InvenTree/dev/secret_key.txt",
|
||||
"INVENTREE_PLUGIN_DIR": "/workspaces/InvenTree/dev/plugins",
|
||||
"INVENTREE_PLUGIN_FILE": "/workspaces/InvenTree/dev/plugins.txt",
|
||||
|
||||
// Python config
|
||||
"PIP_USER": "no",
|
||||
|
||||
// used to load the venv into the PATH and avtivate it
|
||||
// Ref: https://stackoverflow.com/a/56286534
|
||||
"VIRTUAL_ENV": "/workspaces/InvenTree/dev/venv",
|
||||
"PATH": "/workspaces/InvenTree/dev/venv/bin:${containerEnv:PATH}"
|
||||
}
|
||||
}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# create folders
|
||||
mkdir -p /workspaces/InvenTree/dev/{commandhistory,plugins}
|
||||
cd /workspaces/InvenTree
|
||||
|
||||
# create venv
|
||||
python3 -m venv dev/venv
|
||||
. dev/venv/bin/activate
|
||||
|
||||
# setup inventree server
|
||||
pip install invoke
|
||||
inv update
|
||||
inv setup-dev
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
name: Bug
|
||||
about: Create a bug report to help us improve InvenTree!
|
||||
title: "[BUG] Enter bug description"
|
||||
labels: bug, question
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!---
|
||||
Everything inside these brackets is hidden - please remove them where you fill out information.
|
||||
--->
|
||||
|
||||
|
||||
**Describe the bug**
|
||||
<!---
|
||||
A clear and concise description of what the bug is.
|
||||
--->
|
||||
|
||||
**Steps to Reproduce**
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
<!---
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
--->
|
||||
|
||||
**Expected behavior**
|
||||
<!---
|
||||
A clear and concise description of what you expected to happen.
|
||||
--->
|
||||
|
||||
<!---
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
--->
|
||||
|
||||
**Deployment Method**
|
||||
- [ ] Docker
|
||||
- [ ] Bare Metal
|
||||
|
||||
**Version Information**
|
||||
<!---
|
||||
You can get this by going to the "About InvenTree" section in the upper right corner and clicking on to the "copy version information"
|
||||
--->
|
||||
@@ -0,0 +1,62 @@
|
||||
name: "Bug"
|
||||
description: "Create a bug report to help us improve InvenTree!"
|
||||
labels: ["bug", "question", "triage:not-checked"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: no-duplicate-issues
|
||||
attributes:
|
||||
label: "Please verify that this bug has NOT been raised before."
|
||||
description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=)"
|
||||
options:
|
||||
- label: "I checked and didn't find similar issue"
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Describe the bug*"
|
||||
description: "A clear and concise description of what the bug is."
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Steps to Reproduce"
|
||||
description: "Steps to reproduce the behavior, please make it detailed"
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Expected behavior"
|
||||
description: "A clear and concise description of what you expected to happen."
|
||||
placeholder: "..."
|
||||
- type: checkboxes
|
||||
id: deployment
|
||||
attributes:
|
||||
label: "Deployment Method"
|
||||
options:
|
||||
- label: "Docker"
|
||||
- label: "Bare metal"
|
||||
- type: textarea
|
||||
id: version-info
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Version Information"
|
||||
description: "The version info block."
|
||||
placeholder: "You can get this by going to the `About InvenTree` section in the upper right corner and clicking on to the `copy version information`"
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: "Relevant log output"
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FR]"
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request the result of a bug?**
|
||||
Please link it here.
|
||||
|
||||
**Problem**
|
||||
A clear and concise description of what the problem is. e.g. I'm always frustrated when [...]
|
||||
|
||||
**Suggested solution**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Examples of other systems**
|
||||
Show how other software handles your FR if you have examples.
|
||||
|
||||
**Do you want to develop this?**
|
||||
If so please describe briefly how you would like to implement it (so we can give advice) and if you have experience in the needed technology (you do not need to be a pro - this is just as a information for us).
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[FR] title"
|
||||
labels: ["enhancement", "triage:not-checked"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: no-duplicate-issues
|
||||
attributes:
|
||||
label: "Please verify that this feature request has NOT been suggested before."
|
||||
description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=)"
|
||||
options:
|
||||
- label: "I checked and didn't find similar feature request"
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Problem statement"
|
||||
description: "A clear and concise description of what the solved problem or feature request is."
|
||||
placeholder: "I am always struggeling with ..."
|
||||
- type: textarea
|
||||
id: solution
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Suggested solution"
|
||||
description: "A clear and concise description of what you want to happen."
|
||||
placeholder: "In my use-case, ..."
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Describe alternatives you've considered"
|
||||
description: "A clear and concise description of any alternative solutions or features you've considered."
|
||||
placeholder: "This could also be done by doing ..."
|
||||
- type: textarea
|
||||
id: examples
|
||||
validations:
|
||||
required: false
|
||||
attributes:
|
||||
label: "Examples of other systems"
|
||||
description: "Show how other software handles your FR if you have examples."
|
||||
placeholder: "I software xxx this is done in the following way..."
|
||||
- type: checkboxes
|
||||
id: self-develop
|
||||
attributes:
|
||||
label: "Do you want to develop this?"
|
||||
description: "you do not need to be a pro - this is just as a information for us"
|
||||
options:
|
||||
- label: "I want to develop this."
|
||||
required: false
|
||||
@@ -21,10 +21,9 @@ jobs:
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
||||
@@ -15,7 +15,7 @@ name: Docker
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
types: [ published ]
|
||||
|
||||
push:
|
||||
branches:
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
||||
- name: Version Check
|
||||
run: |
|
||||
pip install requests
|
||||
@@ -66,30 +66,30 @@ jobs:
|
||||
test -f data/secret_key.txt
|
||||
- name: Set up QEMU
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480 # pin@v1
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@f211e3e9ded2d9377c8cadc4489a4e38014bc4c9 # pin@v1
|
||||
- name: Set up cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@48866aa521d8bf870604709cd43ec2f602d03ff2
|
||||
uses: sigstore/cosign-installer@09a077b27eb1310dcfb21981bee195b30ce09de0 # pin@v2.5.0
|
||||
- name: Login to Dockerhub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7 # pin@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract Docker metadata
|
||||
if: github.event_name != 'pull_request'
|
||||
id: meta
|
||||
uses: docker/metadata-action@69f6fc9d46f2f8bf0d5491e4aabe0bb8c6a4678a
|
||||
uses: docker/metadata-action@69f6fc9d46f2f8bf0d5491e4aabe0bb8c6a4678a # pin@v4.0.1
|
||||
with:
|
||||
images: |
|
||||
inventree/inventree
|
||||
- name: Build and Push
|
||||
id: build-and-push
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a # pin@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
@@ -103,11 +103,5 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
COSIGN_EXPERIMENTAL: "true"
|
||||
run: cosign sign ${{ steps.meta.outputs.tags }}@${{ steps.build-and-push.outputs.digest }}
|
||||
- name: Push to Stable Branch
|
||||
uses: ad-m/github-push-action@master
|
||||
if: env.stable_release == 'true' && github.event_name != 'pull_request'
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: stable
|
||||
force: true
|
||||
run: cosign sign ${{ steps.meta.outputs.tags }}@${{
|
||||
steps.build-and-push.outputs.digest }}
|
||||
|
||||
@@ -15,7 +15,6 @@ env:
|
||||
python_version: 3.9
|
||||
node_version: 16
|
||||
# The OS version must be set per job
|
||||
|
||||
server_start_sleep: 60
|
||||
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -30,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -45,7 +44,7 @@ jobs:
|
||||
needs: pep_style
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -67,7 +66,7 @@ jobs:
|
||||
needs: pep_style
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -83,18 +82,18 @@ jobs:
|
||||
needs: pep_style
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ env.python_version }}
|
||||
cache: 'pip'
|
||||
- name: Run pre-commit Checks
|
||||
uses: pre-commit/action@v2.0.3
|
||||
- name: Check Version
|
||||
run: |
|
||||
pip install requests
|
||||
python3 ci/version_check.py
|
||||
- uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
uses: actions/setup-python@7f80679172b057fc5e90d70d197929d454754a5a # pin@v2
|
||||
with:
|
||||
python-version: ${{ env.python_version }}
|
||||
cache: 'pip'
|
||||
- name: Run pre-commit Checks
|
||||
uses: pre-commit/action@9b88afc9cd57fd75b655d5c71bd38146d07135fe # pin@v2.0.3
|
||||
- name: Check Version
|
||||
run: |
|
||||
pip install requests
|
||||
python3 ci/version_check.py
|
||||
|
||||
python:
|
||||
name: Tests - inventree-python
|
||||
@@ -114,7 +113,7 @@ jobs:
|
||||
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -122,7 +121,8 @@ jobs:
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Download Python Code For `${{ env.wrapper_name }}`
|
||||
run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }}
|
||||
run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }}
|
||||
./${{ env.wrapper_name }}
|
||||
- name: Start InvenTree Server
|
||||
run: |
|
||||
invoke delete-data -f
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -155,8 +155,8 @@ jobs:
|
||||
name: Tests - DB [SQLite] + Coverage
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: ['javascript', 'html', 'pre-commit']
|
||||
continue-on-error: true # continue if a step fails so that coverage gets pushed
|
||||
needs: [ 'javascript', 'html', 'pre-commit' ]
|
||||
continue-on-error: true # continue if a step fails so that coverage gets pushed
|
||||
|
||||
env:
|
||||
INVENTREE_DB_NAME: ./inventree.sqlite
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -185,9 +185,7 @@ jobs:
|
||||
postgres:
|
||||
name: Tests - DB [PostgreSQL]
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: ['javascript', 'html', 'pre-commit']
|
||||
if: github.event_name == 'push'
|
||||
needs: [ 'javascript', 'html', 'pre-commit' ]
|
||||
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: django.db.backends.postgresql
|
||||
@@ -214,7 +212,7 @@ jobs:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -231,7 +229,7 @@ jobs:
|
||||
name: Tests - DB [MySQL]
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: ['javascript', 'html', 'pre-commit']
|
||||
needs: [ 'javascript', 'html', 'pre-commit' ]
|
||||
if: github.event_name == 'push'
|
||||
|
||||
env:
|
||||
@@ -253,12 +251,13 @@ jobs:
|
||||
MYSQL_USER: inventree
|
||||
MYSQL_PASSWORD: password
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s
|
||||
--health-retries=3
|
||||
ports:
|
||||
- 3306:3306
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
|
||||
@@ -3,15 +3,35 @@
|
||||
name: Publish release notes
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
|
||||
stable:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
||||
- name: Version Check
|
||||
run: |
|
||||
pip install requests
|
||||
python3 ci/version_check.py
|
||||
- name: Push to Stable Branch
|
||||
uses: ad-m/github-push-action@9a46ba8d86d3171233e861a4351b1278a2805c83 # pin@master
|
||||
if: env.stable_release == 'true'
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: stable
|
||||
force: true
|
||||
|
||||
tweet:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Eomm/why-don-t-you-tweet@v1
|
||||
- uses: Eomm/why-don-t-you-tweet@f61f2a86c30c46528c1398a1abb1f64aa0988f69 # pin@v1
|
||||
with:
|
||||
tweet-message: "InvenTree release ${{ github.event.release.tag_name }} is out now! Release notes: ${{ github.event.release.html_url }} #opensource #inventree"
|
||||
tweet-message: "InvenTree release ${{ github.event.release.tag_name }} is out
|
||||
now! Release notes: ${{ github.event.release.html_url }} #opensource
|
||||
#inventree"
|
||||
env:
|
||||
TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }}
|
||||
TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }}
|
||||
@@ -19,14 +39,14 @@ jobs:
|
||||
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
|
||||
|
||||
reddit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: bluwy/release-for-reddit-action@v1
|
||||
with:
|
||||
username: ${{ secrets.REDDIT_USERNAME }}
|
||||
password: ${{ secrets.REDDIT_PASSWORD }}
|
||||
app-id: ${{ secrets.REDDIT_APP_ID }}
|
||||
app-secret: ${{ secrets.REDDIT_APP_SECRET }}
|
||||
subreddit: InvenTree
|
||||
title: "InvenTree version ${{ github.event.release.tag_name }} released"
|
||||
comment: "${{ github.event.release.body }}"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: bluwy/release-for-reddit-action@4d948192aff856da22f19f9806b00b46ca384547 # pin@v1
|
||||
with:
|
||||
username: ${{ secrets.REDDIT_USERNAME }}
|
||||
password: ${{ secrets.REDDIT_PASSWORD }}
|
||||
app-id: ${{ secrets.REDDIT_APP_ID }}
|
||||
app-secret: ${{ secrets.REDDIT_APP_SECRET }}
|
||||
subreddit: InvenTree
|
||||
title: "InvenTree version ${{ github.event.release.tag_name }} released"
|
||||
comment: "${{ github.event.release.body }}"
|
||||
|
||||
+11
-10
@@ -3,7 +3,7 @@ name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '24 11 * * *'
|
||||
- cron: '24 11 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
@@ -14,12 +14,13 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
|
||||
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
||||
stale-issue-label: 'inactive'
|
||||
stale-pr-label: 'inactive'
|
||||
start-date: '2022-01-01'
|
||||
exempt-all-milestones: true
|
||||
- uses: actions/stale@98ed4cb500039dbcccf4bd9bedada4d0187f2757 # pin@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue seems stale. Please react to show this is still
|
||||
important.'
|
||||
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
||||
stale-issue-label: 'inactive'
|
||||
stale-pr-label: 'inactive'
|
||||
start-date: '2022-01-01'
|
||||
exempt-all-milestones: true
|
||||
|
||||
@@ -20,17 +20,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@152ba7c4dd6521b8e9c93f72d362ce03bf6c4f20 # pin@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
pip3 install invoke
|
||||
invoke install
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
pip3 install invoke
|
||||
invoke install
|
||||
- name: Make Translations
|
||||
run: |
|
||||
invoke translate
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
git add "*.po"
|
||||
git commit -m "updated translation base"
|
||||
- name: Push changes
|
||||
uses: ad-m/github-push-action@master
|
||||
uses: ad-m/github-push-action@9a46ba8d86d3171233e861a4351b1278a2805c83 # pin@master
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: l10
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Update dependency files regularly
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: null
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
@@ -9,14 +9,15 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
|
||||
- name: Setup
|
||||
run: pip install -r requirements-dev.txt
|
||||
- name: Update requirements.txt
|
||||
run: pip-compile --output-file=requirements.txt requirements.in -U
|
||||
- name: Update requirements-dev.txt
|
||||
run: pip-compile --generate-hashes --output-file=requirements-dev.txt requirements-dev.in -U
|
||||
- uses: stefanzweifel/git-auto-commit-action@v4
|
||||
run: pip-compile --generate-hashes --output-file=requirements-dev.txt
|
||||
requirements-dev.in -U
|
||||
- uses: stefanzweifel/git-auto-commit-action@49620cd3ed21ee620a48530e81dba0d139c9cb80 # pin@v4
|
||||
with:
|
||||
commit_message: "[Bot] Updated dependency"
|
||||
branch: dep-update
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
name: Welcome
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
types: [ opened ]
|
||||
issues:
|
||||
types: [opened]
|
||||
types: [ opened ]
|
||||
|
||||
jobs:
|
||||
run:
|
||||
@@ -13,13 +13,13 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: |
|
||||
Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.
|
||||
If you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).
|
||||
pr-message: |
|
||||
This is your first PR, welcome!
|
||||
Please check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.
|
||||
Make sure to document why this PR is needed and to link connected issues so we can review it faster.
|
||||
- uses: actions/first-interaction@bd33205aa5c96838e10fd65df0d01efd613677c1 # pin@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: |
|
||||
Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.
|
||||
If you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).
|
||||
pr-message: |
|
||||
This is your first PR, welcome!
|
||||
Please check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.
|
||||
Make sure to document why this PR is needed and to link connected issues so we can review it faster.
|
||||
|
||||
+8
-1
@@ -66,9 +66,16 @@ secret_key.txt
|
||||
# IDE / development files
|
||||
.idea/
|
||||
*.code-workspace
|
||||
.vscode/
|
||||
.bash_history
|
||||
|
||||
# https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
.vscode/*
|
||||
#!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
#!.vscode/extensions.json
|
||||
#!.vscode/*.code-snippets
|
||||
|
||||
# Coverage reports
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "InvenTree Server",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/InvenTree/manage.py",
|
||||
"args": ["runserver"],
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "Python: Django - 3rd party",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/InvenTree/manage.py",
|
||||
"args": ["runserver"],
|
||||
"django": true,
|
||||
"justMyCode": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "clean-settings",
|
||||
"type": "shell",
|
||||
"command": "inv clean-settings",
|
||||
},
|
||||
{
|
||||
"label": "delete-data",
|
||||
"type": "shell",
|
||||
"command": "inv delete-data",
|
||||
},
|
||||
{
|
||||
"label": "migrate",
|
||||
"type": "shell",
|
||||
"command": "inv migrate",
|
||||
},
|
||||
{
|
||||
"label": "server",
|
||||
"type": "shell",
|
||||
"command": "inv server",
|
||||
},
|
||||
{
|
||||
"label": "setup-dev",
|
||||
"type": "shell",
|
||||
"command": "inv setup-dev",
|
||||
},
|
||||
{
|
||||
"label": "setup-test",
|
||||
"type": "shell",
|
||||
"command": "inv setup-test --path dev/inventree-demo-dataset",
|
||||
},
|
||||
{
|
||||
"label": "superuser",
|
||||
"type": "shell",
|
||||
"command": "inv superuser",
|
||||
},
|
||||
{
|
||||
"label": "test",
|
||||
"type": "shell",
|
||||
"command": "inv test",
|
||||
},
|
||||
{
|
||||
"label": "update",
|
||||
"type": "shell",
|
||||
"command": "inv update",
|
||||
},
|
||||
]
|
||||
}
|
||||
+4
-1
@@ -147,7 +147,7 @@ Any user-facing strings *must* be passed through the translation engine.
|
||||
For strings exposed via Python code, use the following format:
|
||||
|
||||
```python
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
user_facing_string = _('This string will be exposed to the translation engine!')
|
||||
```
|
||||
@@ -167,6 +167,9 @@ HTML and javascript files are passed through the django templating engine. Trans
|
||||
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 |
|
||||
|
||||
@@ -12,6 +12,7 @@ from rest_framework.serializers import ValidationError
|
||||
|
||||
from InvenTree.mixins import ListCreateAPI
|
||||
from InvenTree.permissions import RolePermission
|
||||
from part.templatetags.inventree_extras import plugins_info
|
||||
|
||||
from .status import is_worker_running
|
||||
from .version import (inventreeApiVersion, inventreeInstanceName,
|
||||
@@ -36,6 +37,7 @@ class InfoView(AjaxView):
|
||||
'apiVersion': inventreeApiVersion(),
|
||||
'worker_running': is_worker_running(),
|
||||
'plugins_enabled': settings.PLUGINS_ENABLED,
|
||||
'active_plugins': plugins_info(),
|
||||
}
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
@@ -2,11 +2,24 @@
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 70
|
||||
INVENTREE_API_VERSION = 74
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v74 -> 2022-08-28 : https://github.com/inventree/InvenTree/pull/3615
|
||||
- Add confirmation field for completing PurchaseOrder if the order has incomplete lines
|
||||
- Add confirmation field for completing SalesOrder if the order has incomplete lines
|
||||
|
||||
v73 -> 2022-08-24 : https://github.com/inventree/InvenTree/pull/3605
|
||||
- Add 'description' field to PartParameterTemplate model
|
||||
|
||||
v72 -> 2022-08-18 : https://github.com/inventree/InvenTree/pull/3567
|
||||
- Allow PurchaseOrder to be duplicated via the API
|
||||
|
||||
v71 -> 2022-08-18 : https://github.com/inventree/InvenTree/pull/3564
|
||||
- Updates to the "part scheduling" API endpoint
|
||||
|
||||
v70 -> 2022-08-02 : https://github.com/inventree/InvenTree/pull/3451
|
||||
- Adds a 'depth' parameter to the PartCategory list API
|
||||
- Adds a 'depth' parameter to the StockLocation list API
|
||||
|
||||
@@ -203,3 +203,30 @@ def get_secret_key():
|
||||
key_data = secret_key_file.read_text().strip()
|
||||
|
||||
return key_data
|
||||
|
||||
|
||||
def get_custom_file(env_ref: str, conf_ref: str, log_ref: str, lookup_media: bool = False):
|
||||
"""Returns the checked path to a custom file.
|
||||
|
||||
Set lookup_media to True to also search in the media folder.
|
||||
"""
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
value = get_setting(env_ref, conf_ref, None)
|
||||
|
||||
if not value:
|
||||
return None
|
||||
|
||||
static_storage = StaticFilesStorage()
|
||||
|
||||
if static_storage.exists(value):
|
||||
logger.info(f"Loading {log_ref} from static directory: {value}")
|
||||
elif lookup_media and default_storage.exists(value):
|
||||
logger.info(f"Loading {log_ref} from media directory: {value}")
|
||||
else:
|
||||
add_dir_str = ' or media' if lookup_media else ''
|
||||
logger.warning(f"The {log_ref} file '{value}' could not be found in the static{add_dir_str} directories")
|
||||
value = False
|
||||
|
||||
return value
|
||||
|
||||
@@ -20,7 +20,9 @@ from django.http import StreamingHttpResponse
|
||||
from django.test import TestCase
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import regex
|
||||
import requests
|
||||
from bleach import clean
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
|
||||
@@ -265,6 +267,20 @@ def getLogoImage(as_file=False, custom=True):
|
||||
return getStaticUrl('img/inventree.png')
|
||||
|
||||
|
||||
def getSplashScren(custom=True):
|
||||
"""Return the InvenTree splash screen, or a custom splash if available"""
|
||||
|
||||
static_storage = StaticFilesStorage()
|
||||
|
||||
if custom and settings.CUSTOM_SPLASH:
|
||||
|
||||
if static_storage.exists(settings.CUSTOM_SPLASH):
|
||||
return static_storage.url(settings.CUSTOM_SPLASH)
|
||||
|
||||
# No custom splash screen
|
||||
return static_storage.url("img/inventree_splash.jpg")
|
||||
|
||||
|
||||
def TestIfImageURL(url):
|
||||
"""Test if an image URL (or filename) looks like a valid image format.
|
||||
|
||||
@@ -842,6 +858,55 @@ def clean_decimal(number):
|
||||
return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()
|
||||
|
||||
|
||||
def strip_html_tags(value: str, raise_error=True, field_name=None):
|
||||
"""Strip HTML tags from an input string using the bleach library.
|
||||
|
||||
If raise_error is True, a ValidationError will be thrown if HTML tags are detected
|
||||
"""
|
||||
|
||||
cleaned = clean(
|
||||
value,
|
||||
strip=True,
|
||||
tags=[],
|
||||
attributes=[],
|
||||
)
|
||||
|
||||
# Add escaped characters back in
|
||||
replacements = {
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'&': '&',
|
||||
}
|
||||
|
||||
for o, r in replacements.items():
|
||||
cleaned = cleaned.replace(o, r)
|
||||
|
||||
# If the length changed, it means that HTML tags were removed!
|
||||
if len(cleaned) != len(value) and raise_error:
|
||||
|
||||
field = field_name or 'non_field_errors'
|
||||
|
||||
raise ValidationError({
|
||||
field: [_("Remove HTML tags from this value")]
|
||||
})
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def remove_non_printable_characters(value: str, remove_ascii=True, remove_unicode=True):
|
||||
"""Remove non-printable / control characters from the provided string"""
|
||||
|
||||
if remove_ascii:
|
||||
# Remove ASCII control characters
|
||||
cleaned = regex.sub(u'[\x01-\x1F]+', '', value)
|
||||
|
||||
if remove_unicode:
|
||||
# Remove Unicode control characters
|
||||
cleaned = regex.sub(u'[^\P{C}]+', '', value)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = 'object_id'):
|
||||
"""Lookup method for the GenericForeignKey fields.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.conf import settings
|
||||
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import Resolver404, include, re_path, reverse_lazy
|
||||
from django.urls import Resolver404, include, re_path, resolve, reverse_lazy
|
||||
|
||||
from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
|
||||
BaseRequire2FAMiddleware)
|
||||
@@ -41,6 +41,11 @@ class AuthRequiredMiddleware(object):
|
||||
if request.path_info.startswith('/api/'):
|
||||
return self.get_response(request)
|
||||
|
||||
# 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)
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
"""
|
||||
Normally, a web-based session would use csrftoken based authentication.
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""Mixins for (API) views in the whole project."""
|
||||
|
||||
from bleach import clean
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
|
||||
|
||||
|
||||
class CleanMixin():
|
||||
"""Model mixin class which cleans inputs."""
|
||||
"""Model mixin class which cleans inputs using the Mozilla bleach tools."""
|
||||
|
||||
# Define a map of fields avaialble for import
|
||||
SAFE_FIELDS = {}
|
||||
# Define a list of field names which will *not* be cleaned
|
||||
SAFE_FIELDS = []
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Override to clean data before processing it."""
|
||||
@@ -34,6 +35,22 @@ class CleanMixin():
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def clean_string(self, field: str, data: str) -> str:
|
||||
"""Clean / sanitize a single input string.
|
||||
|
||||
Note that this function will *allow* orphaned <>& characters,
|
||||
which would normally be escaped by bleach.
|
||||
|
||||
Nominally, the only thing that will be "cleaned" will be HTML tags
|
||||
|
||||
Ref: https://github.com/mozilla/bleach/issues/192
|
||||
"""
|
||||
|
||||
cleaned = strip_html_tags(data, field_name=field)
|
||||
cleaned = remove_non_printable_characters(cleaned)
|
||||
|
||||
return cleaned
|
||||
|
||||
def clean_data(self, data: dict) -> dict:
|
||||
"""Clean / sanitize data.
|
||||
|
||||
@@ -46,17 +63,24 @@ class CleanMixin():
|
||||
data (dict): Data that should be sanatized.
|
||||
|
||||
Returns:
|
||||
dict: Profided data sanatized; still in the same order.
|
||||
dict: Provided data sanatized; still in the same order.
|
||||
"""
|
||||
|
||||
clean_data = {}
|
||||
|
||||
for k, v in data.items():
|
||||
if isinstance(v, str):
|
||||
ret = clean(v)
|
||||
|
||||
if k in self.SAFE_FIELDS:
|
||||
ret = v
|
||||
elif isinstance(v, str):
|
||||
ret = self.clean_string(k, v)
|
||||
elif isinstance(v, dict):
|
||||
ret = self.clean_data(v)
|
||||
else:
|
||||
ret = v
|
||||
|
||||
clean_data[k] = ret
|
||||
|
||||
return clean_data
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Permission set for InvenTree."""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
import users.models
|
||||
@@ -63,3 +65,11 @@ class RolePermission(permissions.BasePermission):
|
||||
result = users.models.RuleSet.check_table_permission(user, table, permission)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def auth_exempt(view_func):
|
||||
"""Mark a view function as being exempt from auth requirements."""
|
||||
def wrapped_view(*args, **kwargs):
|
||||
return view_func(*args, **kwargs)
|
||||
wrapped_view.auth_exempt = True
|
||||
return wraps(view_func)(wrapped_view)
|
||||
|
||||
@@ -16,8 +16,6 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import django.conf.locale
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage
|
||||
from django.core.files.storage import default_storage
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -26,7 +24,7 @@ import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
from . import config
|
||||
from .config import get_boolean_setting, get_setting
|
||||
from .config import get_boolean_setting, get_custom_file, get_setting
|
||||
|
||||
# Determine if we are running in "test" mode e.g. "manage.py test"
|
||||
TESTING = 'test' in sys.argv
|
||||
@@ -678,7 +676,7 @@ EMAIL_SUBJECT_PREFIX = get_setting('INVENTREE_EMAIL_PREFIX', 'email.prefix', '[I
|
||||
EMAIL_USE_TLS = get_boolean_setting('INVENTREE_EMAIL_TLS', 'email.tls', False)
|
||||
EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False)
|
||||
|
||||
DEFUALT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '')
|
||||
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '')
|
||||
|
||||
EMAIL_USE_LOCALTIME = False
|
||||
EMAIL_TIMEOUT = 60
|
||||
@@ -818,29 +816,13 @@ PLUGIN_RETRY = CONFIG.get('PLUGIN_RETRY', 5) # how often should plugin loading
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
# User interface customization values
|
||||
CUSTOM_LOGO = get_custom_file('INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True)
|
||||
CUSTOM_SPLASH = get_custom_file('INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash')
|
||||
|
||||
CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {})
|
||||
|
||||
CUSTOM_LOGO = get_setting('INVENTREE_CUSTOM_LOGO', 'customize.logo', None)
|
||||
|
||||
"""
|
||||
Check for the existence of a 'custom logo' file:
|
||||
- Check the 'static' directory
|
||||
- Check the 'media' directory (legacy)
|
||||
"""
|
||||
|
||||
if CUSTOM_LOGO:
|
||||
static_storage = StaticFilesStorage()
|
||||
|
||||
if static_storage.exists(CUSTOM_LOGO):
|
||||
logger.info(f"Loading custom logo from static directory: {CUSTOM_LOGO}")
|
||||
elif default_storage.exists(CUSTOM_LOGO):
|
||||
logger.info(f"Loading custom logo from media directory: {CUSTOM_LOGO}")
|
||||
else:
|
||||
logger.warning(f"The custom logo file '{CUSTOM_LOGO}' could not be found in the static or media directories")
|
||||
CUSTOM_LOGO = False
|
||||
|
||||
if DEBUG:
|
||||
logger.info("InvenTree running with DEBUG enabled")
|
||||
|
||||
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
|
||||
@@ -19,8 +19,8 @@ main {
|
||||
}
|
||||
|
||||
.login-screen {
|
||||
background: url(/static/img/paper_splash.jpg) no-repeat center fixed;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
height: 100vh;
|
||||
font-family: 'Numans', sans-serif;
|
||||
color: #eee;
|
||||
@@ -1028,3 +1028,8 @@ a {
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.treeview .node-icon {
|
||||
margin-left: 0.25rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 461 KiB After Width: | Height: | Size: 461 KiB |
@@ -38,6 +38,7 @@ from part.models import PartCategory
|
||||
from users.models import RuleSet, check_user_role
|
||||
|
||||
from .forms import EditUserForm, SetPasswordForm
|
||||
from .helpers import remove_non_printable_characters, strip_html_tags
|
||||
|
||||
|
||||
def auth_request(request):
|
||||
@@ -600,6 +601,9 @@ class SearchView(TemplateView):
|
||||
|
||||
query = request.POST.get('search', '')
|
||||
|
||||
query = strip_html_tags(query, raise_error=False)
|
||||
query = remove_non_printable_characters(query)
|
||||
|
||||
context['query'] = query
|
||||
|
||||
return super(TemplateView, self).render_to_response(context)
|
||||
|
||||
+14
-5
@@ -100,6 +100,8 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
'reference': ['reference_int', 'reference'],
|
||||
}
|
||||
|
||||
ordering = '-reference'
|
||||
|
||||
search_fields = [
|
||||
'reference',
|
||||
'title',
|
||||
@@ -365,6 +367,7 @@ class BuildItemList(ListCreateAPI):
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['build_detail'] = str2bool(params.get('build_detail', False))
|
||||
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
|
||||
kwargs['stock_detail'] = str2bool(params.get('stock_detail', True))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@@ -372,13 +375,19 @@ class BuildItemList(ListCreateAPI):
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method, to allow filtering by stock_item.part."""
|
||||
query = BuildItem.objects.all()
|
||||
queryset = BuildItem.objects.all()
|
||||
|
||||
query = query.select_related('stock_item__location')
|
||||
query = query.select_related('stock_item__part')
|
||||
query = query.select_related('stock_item__part__category')
|
||||
queryset = queryset.select_related(
|
||||
'bom_item',
|
||||
'bom_item__sub_part',
|
||||
'build',
|
||||
'install_into',
|
||||
'stock_item',
|
||||
'stock_item__location',
|
||||
'stock_item__part',
|
||||
)
|
||||
|
||||
return query
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Customm query filtering for the BuildItem list."""
|
||||
|
||||
+46
-20
@@ -674,28 +674,26 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
parts = bom_item.get_valid_parts_for_allocation()
|
||||
|
||||
for part in parts:
|
||||
items = StockModels.StockItem.objects.filter(
|
||||
part__in=parts,
|
||||
serial=str(serial),
|
||||
quantity=1,
|
||||
).filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
items = StockModels.StockItem.objects.filter(
|
||||
part=part,
|
||||
serial=str(serial),
|
||||
"""
|
||||
Test if there is a matching serial number!
|
||||
"""
|
||||
if items.exists() and items.count() == 1:
|
||||
stock_item = items[0]
|
||||
|
||||
# Allocate the stock item
|
||||
BuildItem.objects.create(
|
||||
build=self,
|
||||
bom_item=bom_item,
|
||||
stock_item=stock_item,
|
||||
quantity=1,
|
||||
).filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
"""
|
||||
Test if there is a matching serial number!
|
||||
"""
|
||||
if items.exists() and items.count() == 1:
|
||||
stock_item = items[0]
|
||||
|
||||
# Allocate the stock item
|
||||
BuildItem.objects.create(
|
||||
build=self,
|
||||
bom_item=bom_item,
|
||||
stock_item=stock_item,
|
||||
quantity=1,
|
||||
install_into=output,
|
||||
)
|
||||
install_into=output,
|
||||
)
|
||||
|
||||
else:
|
||||
"""Create a single build output of the given quantity."""
|
||||
@@ -736,6 +734,34 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
# Remove the build output from the database
|
||||
output.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def trim_allocated_stock(self):
|
||||
"""Called after save to reduce allocated stock if the build order is now overallocated."""
|
||||
allocations = BuildItem.objects.filter(build=self)
|
||||
|
||||
# Only need to worry about untracked stock here
|
||||
for bom_item in self.untracked_bom_items:
|
||||
reduce_by = self.allocated_quantity(bom_item) - self.required_quantity(bom_item)
|
||||
if reduce_by <= 0:
|
||||
continue # all OK
|
||||
|
||||
# find builditem(s) to trim
|
||||
for a in allocations.filter(bom_item=bom_item):
|
||||
# Previous item completed the job
|
||||
if reduce_by == 0:
|
||||
break
|
||||
|
||||
# Easy case - this item can just be reduced.
|
||||
if a.quantity > reduce_by:
|
||||
a.quantity -= reduce_by
|
||||
a.save()
|
||||
break
|
||||
|
||||
# Harder case, this item needs to be deleted, and any remainder
|
||||
# taken from the next items in the list.
|
||||
reduce_by -= a.quantity
|
||||
a.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def subtract_allocated_stock(self, user):
|
||||
"""Called when the Build is marked as "complete", this function removes the allocated untracked items from stock."""
|
||||
|
||||
@@ -473,21 +473,36 @@ class BuildCancelSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class OverallocationChoice():
|
||||
"""Utility class to contain options for handling over allocated stock items."""
|
||||
|
||||
REJECT = 'reject'
|
||||
ACCEPT = 'accept'
|
||||
TRIM = 'trim'
|
||||
|
||||
OPTIONS = {
|
||||
REJECT: ('Not permitted'),
|
||||
ACCEPT: _('Accept as consumed by this build order'),
|
||||
TRIM: _('Deallocate before completing this build order'),
|
||||
}
|
||||
|
||||
|
||||
class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""DRF serializer for marking a BuildOrder as complete."""
|
||||
|
||||
accept_overallocated = serializers.BooleanField(
|
||||
label=_('Accept Overallocated'),
|
||||
help_text=_('Accept stock items which have been overallocated to this build order'),
|
||||
accept_overallocated = serializers.ChoiceField(
|
||||
label=_('Overallocated Stock'),
|
||||
choices=list(OverallocationChoice.OPTIONS.items()),
|
||||
help_text=_('How do you want to handle extra stock items assigned to the build order'),
|
||||
required=False,
|
||||
default=False,
|
||||
default=OverallocationChoice.REJECT,
|
||||
)
|
||||
|
||||
def validate_accept_overallocated(self, value):
|
||||
"""Check if the 'accept_overallocated' field is required"""
|
||||
build = self.context['build']
|
||||
|
||||
if build.has_overallocated_parts(output=None) and not value:
|
||||
if build.has_overallocated_parts(output=None) and value == OverallocationChoice.REJECT:
|
||||
raise ValidationError(_('Some stock items have been overallocated'))
|
||||
|
||||
return value
|
||||
@@ -531,9 +546,6 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
if build.incomplete_count > 0:
|
||||
raise ValidationError(_("Build order has incomplete outputs"))
|
||||
|
||||
if not build.has_build_outputs():
|
||||
raise ValidationError(_("No build outputs have been created for this build order"))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
@@ -541,6 +553,10 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
request = self.context['request']
|
||||
build = self.context['build']
|
||||
|
||||
data = self.validated_data
|
||||
if data.get('accept_overallocated', OverallocationChoice.REJECT) == OverallocationChoice.TRIM:
|
||||
build.trim_allocated_stock()
|
||||
|
||||
build.complete_build(request.user)
|
||||
|
||||
|
||||
@@ -840,6 +856,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
build_detail = kwargs.pop('build_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
stock_detail = kwargs.pop('stock_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -852,6 +869,9 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
if not location_detail:
|
||||
self.fields.pop('location_detail')
|
||||
|
||||
if not stock_detail:
|
||||
self.fields.pop('stock_item_detail')
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
model = BuildItem
|
||||
|
||||
@@ -55,6 +55,9 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% if build.is_active %}
|
||||
<li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
|
||||
{% endif %}
|
||||
{% if roles.build.add %}
|
||||
<li><a class='dropdown-item' href='#' id='build-duplicate'><span class='fas fa-clone'></span> {% trans "Duplicate Build" %}</a></li>
|
||||
{% endif %}
|
||||
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
|
||||
<li><a class='dropdown-item' href='#' id='build-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Build" %}</a>
|
||||
{% endif %}
|
||||
@@ -209,6 +212,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
{% block js_ready %}
|
||||
|
||||
{% if roles.build.change %}
|
||||
$("#build-edit").click(function () {
|
||||
editBuildOrder({{ build.pk }});
|
||||
});
|
||||
@@ -224,24 +228,19 @@ src="{% static 'img/blank_image.png' %}"
|
||||
});
|
||||
|
||||
$("#build-complete").on('click', function() {
|
||||
|
||||
{% if build.incomplete_count > 0 %}
|
||||
showAlertDialog(
|
||||
'{% trans "Incomplete Outputs" %}',
|
||||
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}',
|
||||
{
|
||||
alert_style: 'danger',
|
||||
}
|
||||
);
|
||||
{% else %}
|
||||
|
||||
completeBuildOrder({{ build.pk }}, {
|
||||
overallocated: {% if build.has_overallocated_parts %}true{% else %}false{% endif %},
|
||||
allocated: {% if build.are_untracked_parts_allocated %}true{% else %}false{% endif %},
|
||||
completed: {% if build.remaining == 0 %}true{% else %}false{% endif %},
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if roles.build.add %}
|
||||
$('#build-duplicate').click(function() {
|
||||
duplicateBuildOrder({{ build.pk }});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if report_enabled %}
|
||||
$('#print-build-report').click(function() {
|
||||
|
||||
@@ -455,7 +455,9 @@ function loadUntrackedStockTable() {
|
||||
);
|
||||
}
|
||||
|
||||
loadUntrackedStockTable();
|
||||
onPanelLoad('allocate', function() {
|
||||
loadUntrackedStockTable();
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -717,6 +717,105 @@ class BuildAllocationTest(BuildAPITest):
|
||||
self.assertEqual(allocation.stock_item.pk, 2)
|
||||
|
||||
|
||||
class BuildOverallocationTest(BuildAPITest):
|
||||
"""Unit tests for over allocation of stock items against a build order.
|
||||
|
||||
Using same Build ID=1 as allocation test above.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Basic operation as part of test suite setup"""
|
||||
super().setUp()
|
||||
|
||||
self.assignRole('build.add')
|
||||
self.assignRole('build.change')
|
||||
|
||||
self.build = Build.objects.get(pk=1)
|
||||
self.url = reverse('api-build-finish', kwargs={'pk': self.build.pk})
|
||||
|
||||
StockItem.objects.create(part=Part.objects.get(pk=50), quantity=30)
|
||||
|
||||
# Keep some state for use in later assertions, and then overallocate
|
||||
self.state = {}
|
||||
self.allocation = {}
|
||||
for i, bi in enumerate(self.build.part.bom_items.all()):
|
||||
rq = self.build.required_quantity(bi, None) + i + 1
|
||||
si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first()
|
||||
|
||||
self.state[bi.sub_part] = (si, si.quantity, rq)
|
||||
BuildItem.objects.create(
|
||||
build=self.build,
|
||||
stock_item=si,
|
||||
quantity=rq,
|
||||
)
|
||||
|
||||
# create and complete outputs
|
||||
self.build.create_build_output(self.build.quantity)
|
||||
outputs = self.build.build_outputs.all()
|
||||
self.build.complete_build_output(outputs[0], self.user)
|
||||
|
||||
# Validate expected state after set-up.
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
||||
self.assertEqual(self.build.complete_outputs.count(), 1)
|
||||
self.assertEqual(self.build.completed, self.build.quantity)
|
||||
|
||||
def test_overallocated_requires_acceptance(self):
|
||||
"""Test build order cannot complete with overallocated items."""
|
||||
# Try to complete the build (it should fail due to overallocation)
|
||||
response = self.post(
|
||||
self.url,
|
||||
{},
|
||||
expected_code=400
|
||||
)
|
||||
self.assertTrue('accept_overallocated' in response.data)
|
||||
|
||||
# Check stock items have not reduced at all
|
||||
for si, oq, _ in self.state.values():
|
||||
si.refresh_from_db()
|
||||
self.assertEqual(si.quantity, oq)
|
||||
|
||||
# Accept overallocated stock
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'accept_overallocated': 'accept',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.build.refresh_from_db()
|
||||
|
||||
# Build should have been marked as complete
|
||||
self.assertTrue(self.build.is_complete)
|
||||
|
||||
# Check stock items have reduced in-line with the overallocation
|
||||
for si, oq, rq in self.state.values():
|
||||
si.refresh_from_db()
|
||||
self.assertEqual(si.quantity, oq - rq)
|
||||
|
||||
def test_overallocated_can_trim(self):
|
||||
"""Test build order will trim/de-allocate overallocated stock when requested."""
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'accept_overallocated': 'trim',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.build.refresh_from_db()
|
||||
|
||||
# Build should have been marked as complete
|
||||
self.assertTrue(self.build.is_complete)
|
||||
|
||||
# Check stock items have reduced only by bom requirement (overallocation trimmed)
|
||||
for bi in self.build.part.bom_items.all():
|
||||
si, oq, _ = self.state[bi.sub_part]
|
||||
rq = self.build.required_quantity(bi, None)
|
||||
si.refresh_from_db()
|
||||
self.assertEqual(si.quantity, oq - rq)
|
||||
|
||||
|
||||
class BuildListTest(BuildAPITest):
|
||||
"""Tests for the BuildOrder LIST API."""
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Sum
|
||||
|
||||
from InvenTree import status_codes as status
|
||||
|
||||
@@ -17,6 +18,9 @@ from part.models import Part, BomItem, BomItemSubstitute
|
||||
from stock.models import StockItem
|
||||
from users.models import Owner
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class BuildTestBase(TestCase):
|
||||
"""Run some tests to ensure that the Build model is working properly."""
|
||||
@@ -120,9 +124,9 @@ class BuildTestBase(TestCase):
|
||||
|
||||
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_3 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_4 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_5 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
|
||||
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
|
||||
|
||||
@@ -375,6 +379,65 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
self.assertTrue(self.build.are_untracked_parts_allocated())
|
||||
|
||||
def test_overallocation_and_trim(self):
|
||||
"""Test overallocation of stock and trim function"""
|
||||
|
||||
# Fully allocate tracked stock (not eligible for trimming)
|
||||
self.allocate_stock(
|
||||
self.output_1,
|
||||
{
|
||||
self.stock_3_1: 6,
|
||||
}
|
||||
)
|
||||
self.allocate_stock(
|
||||
self.output_2,
|
||||
{
|
||||
self.stock_3_1: 14,
|
||||
}
|
||||
)
|
||||
# Fully allocate part 1 (should be left alone)
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_1: 3,
|
||||
self.stock_1_2: 47,
|
||||
}
|
||||
)
|
||||
|
||||
extra_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=6)
|
||||
extra_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=4)
|
||||
|
||||
# Overallocate part 2 (30 needed)
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_2_1: 5,
|
||||
self.stock_2_2: 5,
|
||||
self.stock_2_3: 5,
|
||||
self.stock_2_4: 5,
|
||||
self.stock_2_5: 5, # 25
|
||||
extra_2_1: 6, # 31
|
||||
extra_2_2: 4, # 35
|
||||
}
|
||||
)
|
||||
self.assertTrue(self.build.has_overallocated_parts(None))
|
||||
|
||||
self.build.trim_allocated_stock()
|
||||
self.assertFalse(self.build.has_overallocated_parts(None))
|
||||
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
self.build.complete_build_output(self.output_2, None)
|
||||
self.assertTrue(self.build.can_complete)
|
||||
|
||||
self.build.complete_build(None)
|
||||
|
||||
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
|
||||
|
||||
# Check stock items are in expected state.
|
||||
self.assertEqual(StockItem.objects.get(pk=self.stock_1_2.pk).quantity, 53)
|
||||
self.assertEqual(StockItem.objects.filter(part=self.sub_part_2).aggregate(Sum('quantity'))['quantity__sum'], 5)
|
||||
self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
|
||||
|
||||
def test_cancel(self):
|
||||
"""Test cancellation of the build"""
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
for k, v in kwargs.items():
|
||||
key += f"_{k}:{v}"
|
||||
|
||||
return key
|
||||
return key.replace(" ", "")
|
||||
|
||||
@classmethod
|
||||
def allValues(cls, user=None, exclude_hidden=False):
|
||||
@@ -1070,6 +1070,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': InvenTree.validators.validate_part_name_format
|
||||
},
|
||||
|
||||
'PART_CATEGORY_DEFAULT_ICON': {
|
||||
'name': _('Part Category Default Icon'),
|
||||
'description': _('Part category default icon (empty means no icon)'),
|
||||
'default': '',
|
||||
},
|
||||
|
||||
'LABEL_ENABLE': {
|
||||
'name': _('Enable label printing'),
|
||||
'description': _('Enable label printing from the web interface'),
|
||||
@@ -1168,6 +1174,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'STOCK_LOCATION_DEFAULT_ICON': {
|
||||
'name': _('Stock Location Default Icon'),
|
||||
'description': _('Stock location default icon (empty means no icon)'),
|
||||
'default': '',
|
||||
},
|
||||
|
||||
'BUILDORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Build Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Build Order reference field'),
|
||||
@@ -1268,6 +1280,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'requires_restart': True,
|
||||
},
|
||||
|
||||
'PLUGIN_CHECK_SIGNATURES': {
|
||||
'name': _('Check plugin signatures'),
|
||||
'description': _('Check and show signatures for plugins'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
# Settings for plugin mixin features
|
||||
'ENABLE_PLUGINS_URL': {
|
||||
'name': _('Enable URL integration'),
|
||||
@@ -1310,6 +1329,8 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
}
|
||||
|
||||
typ = 'inventree'
|
||||
|
||||
class Meta:
|
||||
"""Meta options for InvenTreeSetting."""
|
||||
|
||||
@@ -1623,6 +1644,8 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
}
|
||||
|
||||
typ = 'user'
|
||||
|
||||
class Meta:
|
||||
"""Meta options for InvenTreeUserSetting."""
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ class GlobalSettingsSerializer(SettingsSerializer):
|
||||
'choices',
|
||||
'model_name',
|
||||
'api_url',
|
||||
'typ',
|
||||
]
|
||||
|
||||
|
||||
@@ -93,6 +94,7 @@ class UserSettingsSerializer(SettingsSerializer):
|
||||
'choices',
|
||||
'model_name',
|
||||
'api_url',
|
||||
'typ',
|
||||
]
|
||||
|
||||
|
||||
@@ -122,6 +124,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
|
||||
'choices',
|
||||
'model_name',
|
||||
'api_url',
|
||||
'typ',
|
||||
]
|
||||
|
||||
# set Meta class
|
||||
|
||||
@@ -188,5 +188,6 @@ remote_login_header: REMOTE_USER
|
||||
# login_message: InvenTree demo instance - <a href='https://inventree.readthedocs.io/en/latest/demo/'> Click here for login details</a>
|
||||
# navbar_message: <h6>InvenTree demo mode <a href='https://inventree.readthedocs.io/en/latest/demo/'><span class='fas fa-info-circle'></span></a></h6>
|
||||
# logo: logo.png
|
||||
# splash: splash_screen.jpg
|
||||
# hide_admin_link: true
|
||||
# hide_password_reset: true
|
||||
|
||||
@@ -158,16 +158,12 @@ class LabelPrintMixin:
|
||||
|
||||
pages = []
|
||||
|
||||
if len(outputs) > 1:
|
||||
# If more than one output is generated, merge them into a single file
|
||||
for output in outputs:
|
||||
doc = output.get_document()
|
||||
for page in doc.pages:
|
||||
pages.append(page)
|
||||
for output in outputs:
|
||||
doc = output.get_document()
|
||||
for page in doc.pages:
|
||||
pages.append(page)
|
||||
|
||||
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
||||
else:
|
||||
pdf = outputs[0].get_document().write_pdf()
|
||||
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
||||
|
||||
inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user)
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{% load l10n %}
|
||||
{% load report %}
|
||||
{% load barcode %}
|
||||
|
||||
<head>
|
||||
<style>
|
||||
@page {
|
||||
{% localize off %}
|
||||
size: {{ width }}mm {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
{% block margin %}
|
||||
margin: 0mm;
|
||||
{% endblock %}
|
||||
@@ -15,6 +18,8 @@
|
||||
margin: 0mm;
|
||||
color: #000;
|
||||
background-color: #FFF;
|
||||
page-break-before: always;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
img {
|
||||
@@ -22,14 +27,23 @@
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
break-after: always;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
{% block style %}
|
||||
/* User-defined styles can go here */
|
||||
{% endblock %}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block content %}
|
||||
<!-- Label data rendered here! -->
|
||||
{% endblock %}
|
||||
<div class='content'>
|
||||
{% block content %}
|
||||
<!-- Label data rendered here! -->
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "label/label_base.html" %}
|
||||
|
||||
{% load l10n %}
|
||||
{% load barcode %}
|
||||
|
||||
{% block style %}
|
||||
@@ -8,15 +9,19 @@
|
||||
position: fixed;
|
||||
left: 0mm;
|
||||
top: 0mm;
|
||||
{% localize off %}
|
||||
height: {{ height }}mm;
|
||||
width: {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
}
|
||||
|
||||
.part {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
display: inline;
|
||||
position: absolute;
|
||||
{% localize off %}
|
||||
left: {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
top: 2mm;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "label/label_base.html" %}
|
||||
|
||||
{% load l10n %}
|
||||
{% load barcode %}
|
||||
|
||||
{% block style %}
|
||||
@@ -8,15 +9,19 @@
|
||||
position: fixed;
|
||||
left: 0mm;
|
||||
top: 0mm;
|
||||
{% localize off %}
|
||||
height: {{ height }}mm;
|
||||
width: {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
}
|
||||
|
||||
.part {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
display: inline;
|
||||
position: absolute;
|
||||
{% localize off %}
|
||||
left: {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
top: 2mm;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "label/label_base.html" %}
|
||||
|
||||
{% load l10n %}
|
||||
{% load barcode %}
|
||||
|
||||
{% block style %}
|
||||
@@ -8,8 +9,10 @@
|
||||
position: fixed;
|
||||
left: 0mm;
|
||||
top: 0mm;
|
||||
{% localize off %}
|
||||
height: {{ height }}mm;
|
||||
width: {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "label/label_base.html" %}
|
||||
|
||||
{% load l10n %}
|
||||
{% load barcode %}
|
||||
|
||||
{% block style %}
|
||||
@@ -8,8 +9,10 @@
|
||||
position: fixed;
|
||||
left: 0mm;
|
||||
top: 0mm;
|
||||
{% localize off %}
|
||||
height: {{ height }}mm;
|
||||
width: {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "label/label_base.html" %}
|
||||
|
||||
{% load l10n %}
|
||||
{% load barcode %}
|
||||
|
||||
{% block style %}
|
||||
@@ -8,15 +9,19 @@
|
||||
position: fixed;
|
||||
left: 0mm;
|
||||
top: 0mm;
|
||||
{% localize off %}
|
||||
height: {{ height }}mm;
|
||||
width: {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
}
|
||||
|
||||
.loc {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
display: inline;
|
||||
position: absolute;
|
||||
{% localize off %}
|
||||
left: {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
top: 2mm;
|
||||
}
|
||||
|
||||
|
||||
+1535
-1413
File diff suppressed because it is too large
Load Diff
+1527
-1405
File diff suppressed because it is too large
Load Diff
+1554
-1432
File diff suppressed because it is too large
Load Diff
+1580
-1442
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1522
-1400
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1524
-1402
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1530
-1408
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1530
-1408
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1595
-1473
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1522
-1400
File diff suppressed because it is too large
Load Diff
+45
-6
@@ -1,10 +1,13 @@
|
||||
"""JSON API for the Order app."""
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from rest_framework import filters, status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
import order.models as models
|
||||
@@ -116,12 +119,48 @@ class PurchaseOrderList(APIDownloadMixin, ListCreateAPI):
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Save user information on create."""
|
||||
serializer = self.get_serializer(data=self.clean_data(request.data))
|
||||
|
||||
data = self.clean_data(request.data)
|
||||
|
||||
duplicate_order = data.pop('duplicate_order', None)
|
||||
duplicate_line_items = str2bool(data.pop('duplicate_line_items', False))
|
||||
duplicate_extra_lines = str2bool(data.pop('duplicate_extra_lines', False))
|
||||
|
||||
if duplicate_order is not None:
|
||||
try:
|
||||
duplicate_order = models.PurchaseOrder.objects.get(pk=duplicate_order)
|
||||
except (ValueError, models.PurchaseOrder.DoesNotExist):
|
||||
raise ValidationError({
|
||||
'duplicate_order': [_('No matching purchase order found')],
|
||||
})
|
||||
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
item = serializer.save()
|
||||
item.created_by = request.user
|
||||
item.save()
|
||||
with transaction.atomic():
|
||||
order = serializer.save()
|
||||
order.created_by = request.user
|
||||
order.save()
|
||||
|
||||
# Duplicate line items from other order if required
|
||||
if duplicate_order is not None:
|
||||
|
||||
if duplicate_line_items:
|
||||
for line in duplicate_order.lines.all():
|
||||
# Copy the line across to the new order
|
||||
line.pk = None
|
||||
line.order = order
|
||||
line.received = 0
|
||||
|
||||
line.save()
|
||||
|
||||
if duplicate_extra_lines:
|
||||
for line in duplicate_order.extra_lines.all():
|
||||
# Copy the line across to the new order
|
||||
line.pk = None
|
||||
line.order = order
|
||||
|
||||
line.save()
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
@@ -253,7 +292,7 @@ class PurchaseOrderList(APIDownloadMixin, ListCreateAPI):
|
||||
'status',
|
||||
]
|
||||
|
||||
ordering = '-creation_date'
|
||||
ordering = '-reference'
|
||||
|
||||
|
||||
class PurchaseOrderDetail(RetrieveUpdateDestroyAPI):
|
||||
@@ -694,7 +733,7 @@ class SalesOrderList(APIDownloadMixin, ListCreateAPI):
|
||||
'customer_reference',
|
||||
]
|
||||
|
||||
ordering = '-creation_date'
|
||||
ordering = '-reference'
|
||||
|
||||
|
||||
class SalesOrderDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
@@ -702,7 +702,7 @@ class SalesOrder(Order):
|
||||
"""Check if this order is "shipped" (all line items delivered)."""
|
||||
return self.lines.count() > 0 and all([line.is_completed() for line in self.lines.all()])
|
||||
|
||||
def can_complete(self, raise_error=False):
|
||||
def can_complete(self, raise_error=False, allow_incomplete_lines=False):
|
||||
"""Test if this SalesOrder can be completed.
|
||||
|
||||
Throws a ValidationError if cannot be completed.
|
||||
@@ -720,7 +720,7 @@ class SalesOrder(Order):
|
||||
elif self.pending_shipment_count > 0:
|
||||
raise ValidationError(_("Order cannot be completed as there are incomplete shipments"))
|
||||
|
||||
elif self.pending_line_count > 0:
|
||||
elif not allow_incomplete_lines and self.pending_line_count > 0:
|
||||
raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
|
||||
|
||||
except ValidationError as e:
|
||||
@@ -732,9 +732,9 @@ class SalesOrder(Order):
|
||||
|
||||
return True
|
||||
|
||||
def complete_order(self, user):
|
||||
def complete_order(self, user, **kwargs):
|
||||
"""Mark this order as "complete."""
|
||||
if not self.can_complete():
|
||||
if not self.can_complete(**kwargs):
|
||||
return False
|
||||
|
||||
self.status = SalesOrderStatus.SHIPPED
|
||||
|
||||
@@ -19,7 +19,7 @@ import stock.models
|
||||
import stock.serializers
|
||||
from common.settings import currency_code_mappings
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
from InvenTree.helpers import extract_serial_numbers, normalize
|
||||
from InvenTree.helpers import extract_serial_numbers, normalize, str2bool
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
@@ -204,6 +204,23 @@ class PurchaseOrderCancelSerializer(serializers.Serializer):
|
||||
class PurchaseOrderCompleteSerializer(serializers.Serializer):
|
||||
"""Serializer for completing a purchase order."""
|
||||
|
||||
accept_incomplete = serializers.BooleanField(
|
||||
label=_('Accept Incomplete'),
|
||||
help_text=_('Allow order to be closed with incomplete line items'),
|
||||
required=False,
|
||||
default=False,
|
||||
)
|
||||
|
||||
def validate_accept_incomplete(self, value):
|
||||
"""Check if the 'accept_incomplete' field is required"""
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
if not value and not order.is_complete:
|
||||
raise ValidationError(_("Order has incomplete line items"))
|
||||
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@@ -1079,13 +1096,43 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
|
||||
class SalesOrderCompleteSerializer(serializers.Serializer):
|
||||
"""DRF serializer for manually marking a sales order as complete."""
|
||||
|
||||
accept_incomplete = serializers.BooleanField(
|
||||
label=_('Accept Incomplete'),
|
||||
help_text=_('Allow order to be closed with incomplete line items'),
|
||||
required=False,
|
||||
default=False,
|
||||
)
|
||||
|
||||
def validate_accept_incomplete(self, value):
|
||||
"""Check if the 'accept_incomplete' field is required"""
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
if not value and not order.is_completed():
|
||||
raise ValidationError(_("Order has incomplete line items"))
|
||||
|
||||
return value
|
||||
|
||||
def get_context_data(self):
|
||||
"""Custom context data for this serializer"""
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
return {
|
||||
'is_complete': order.is_completed(),
|
||||
'pending_shipments': order.pending_shipment_count,
|
||||
}
|
||||
|
||||
def validate(self, data):
|
||||
"""Custom validation for the serializer"""
|
||||
data = super().validate(data)
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
order.can_complete(raise_error=True)
|
||||
order.can_complete(
|
||||
raise_error=True,
|
||||
allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)),
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -1093,10 +1140,14 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
|
||||
"""Save the serializer to complete the SalesOrder"""
|
||||
request = self.context['request']
|
||||
order = self.context['order']
|
||||
data = self.validated_data
|
||||
|
||||
user = getattr(request, 'user', None)
|
||||
|
||||
order.complete_order(user)
|
||||
order.complete_order(
|
||||
user,
|
||||
allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)),
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderCancelSerializer(serializers.Serializer):
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
{% if order.can_cancel %}
|
||||
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.add %}
|
||||
<li><a class='dropdown-item' href='#' id='duplicate-order'><span class='fas fa-clone'></span> {% trans "Duplicate order" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
@@ -217,30 +220,14 @@ $('#print-order-report').click(function() {
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if roles.purchase_order.change %}
|
||||
|
||||
$("#edit-order").click(function() {
|
||||
|
||||
constructForm('{% url "api-po-detail" order.pk %}', {
|
||||
fields: {
|
||||
reference: {
|
||||
icon: 'fa-hashtag',
|
||||
},
|
||||
{% if order.lines.count == 0 and order.status == PurchaseOrderStatus.PENDING %}
|
||||
supplier: {
|
||||
},
|
||||
{% endif %}
|
||||
supplier_reference: {},
|
||||
description: {},
|
||||
target_date: {
|
||||
icon: 'fa-calendar-alt',
|
||||
},
|
||||
link: {
|
||||
icon: 'fa-link',
|
||||
},
|
||||
responsible: {
|
||||
icon: 'fa-user',
|
||||
},
|
||||
},
|
||||
title: '{% trans "Edit Purchase Order" %}',
|
||||
editPurchaseOrder({{ order.pk }}, {
|
||||
{% if order.lines.count > 0 or order.status != PurchaseOrderStatus.PENDING %}
|
||||
hide_supplier: true,
|
||||
{% endif %}
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
@@ -293,6 +280,16 @@ $("#cancel-order").click(function() {
|
||||
);
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if roles.purchase_order.add %}
|
||||
$('#duplicate-order').click(function() {
|
||||
duplicatePurchaseOrder(
|
||||
{{ order.pk }},
|
||||
);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#export-order").click(function() {
|
||||
exportOrder('{% url "po-export" order.id %}');
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
</div>
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Complete Sales Order" %}'{% if not order.is_completed %} disabled{% endif %}>
|
||||
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Complete Sales Order" %}'>
|
||||
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -253,12 +253,12 @@ $("#cancel-order").click(function() {
|
||||
});
|
||||
|
||||
$("#complete-order").click(function() {
|
||||
constructForm('{% url "api-so-complete" order.id %}', {
|
||||
method: 'POST',
|
||||
title: '{% trans "Complete Sales Order" %}',
|
||||
confirm: true,
|
||||
reload: true,
|
||||
});
|
||||
completeSalesOrder(
|
||||
{{ order.pk }},
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% if report_enabled %}
|
||||
|
||||
@@ -225,6 +225,64 @@ class PurchaseOrderTest(OrderTest):
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
def test_po_duplicate(self):
|
||||
"""Test that we can duplicate a PurchaseOrder via the API"""
|
||||
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
po = models.PurchaseOrder.objects.get(pk=1)
|
||||
|
||||
self.assertTrue(po.lines.count() > 0)
|
||||
|
||||
# Add some extra line items to this order
|
||||
for idx in range(5):
|
||||
models.PurchaseOrderExtraLine.objects.create(
|
||||
order=po,
|
||||
quantity=idx + 10,
|
||||
reference='some reference',
|
||||
)
|
||||
|
||||
data = self.get(reverse('api-po-detail', kwargs={'pk': 1})).data
|
||||
|
||||
del data['pk']
|
||||
del data['reference']
|
||||
|
||||
data['duplicate_order'] = 1
|
||||
data['duplicate_line_items'] = True
|
||||
data['duplicate_extra_lines'] = False
|
||||
|
||||
data['reference'] = 'PO-9999'
|
||||
|
||||
# Duplicate via the API
|
||||
response = self.post(
|
||||
reverse('api-po-list'),
|
||||
data,
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# Order is for the same supplier
|
||||
self.assertEqual(response.data['supplier'], po.supplier.pk)
|
||||
|
||||
po_dup = models.PurchaseOrder.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertEqual(po_dup.extra_lines.count(), 0)
|
||||
self.assertEqual(po_dup.lines.count(), po.lines.count())
|
||||
|
||||
data['reference'] = 'PO-9998'
|
||||
data['duplicate_line_items'] = False
|
||||
data['duplicate_extra_lines'] = True
|
||||
|
||||
response = self.post(
|
||||
reverse('api-po-list'),
|
||||
data,
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
po_dup = models.PurchaseOrder.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertEqual(po_dup.extra_lines.count(), po.extra_lines.count())
|
||||
self.assertEqual(po_dup.lines.count(), 0)
|
||||
|
||||
def test_po_cancel(self):
|
||||
"""Test the PurchaseOrderCancel API endpoint."""
|
||||
po = models.PurchaseOrder.objects.get(pk=1)
|
||||
@@ -264,7 +322,19 @@ class PurchaseOrderTest(OrderTest):
|
||||
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
self.post(url, {}, expected_code=201)
|
||||
# Should fail due to incomplete lines
|
||||
response = self.post(url, {}, expected_code=400)
|
||||
|
||||
self.assertIn('Order has incomplete line items', str(response.data['accept_incomplete']))
|
||||
|
||||
# Post again, accepting incomplete line items
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'accept_incomplete': True,
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
po.refresh_from_db()
|
||||
|
||||
|
||||
+96
-46
@@ -1,6 +1,6 @@
|
||||
"""Provides a JSON API for the Part app."""
|
||||
|
||||
import datetime
|
||||
import functools
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.db import transaction
|
||||
@@ -474,27 +474,27 @@ class PartScheduling(RetrieveAPI):
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""Return scheduling information for the referenced Part instance"""
|
||||
today = datetime.datetime.now().date()
|
||||
|
||||
part = self.get_object()
|
||||
|
||||
schedule = []
|
||||
|
||||
def add_schedule_entry(date, quantity, title, label, url):
|
||||
def add_schedule_entry(date, quantity, title, label, url, speculative_quantity=0):
|
||||
"""Check if a scheduled entry should be added:
|
||||
|
||||
- date must be non-null
|
||||
- date cannot be in the "past"
|
||||
- quantity must not be zero
|
||||
"""
|
||||
if date and date >= today and quantity != 0:
|
||||
schedule.append({
|
||||
'date': date,
|
||||
'quantity': quantity,
|
||||
'title': title,
|
||||
'label': label,
|
||||
'url': url,
|
||||
})
|
||||
|
||||
schedule.append({
|
||||
'date': date,
|
||||
'quantity': quantity,
|
||||
'speculative_quantity': speculative_quantity,
|
||||
'title': title,
|
||||
'label': label,
|
||||
'url': url,
|
||||
})
|
||||
|
||||
# Add purchase order (incoming stock) information
|
||||
po_lines = order.models.PurchaseOrderLineItem.objects.filter(
|
||||
@@ -571,23 +571,94 @@ class PartScheduling(RetrieveAPI):
|
||||
and just looking at what stock items the user has actually allocated against the Build.
|
||||
"""
|
||||
|
||||
build_allocations = BuildItem.objects.filter(
|
||||
stock_item__part=part,
|
||||
build__status__in=BuildStatus.ACTIVE_CODES,
|
||||
)
|
||||
# Grab a list of BomItem objects that this part might be used in
|
||||
bom_items = BomItem.objects.filter(part.get_used_in_bom_item_filter())
|
||||
|
||||
for allocation in build_allocations:
|
||||
# Track all outstanding build orders
|
||||
seen_builds = set()
|
||||
|
||||
add_schedule_entry(
|
||||
allocation.build.target_date,
|
||||
-allocation.quantity,
|
||||
_('Stock required for Build Order'),
|
||||
str(allocation.build),
|
||||
allocation.build.get_absolute_url(),
|
||||
)
|
||||
for bom_item in bom_items:
|
||||
# Find a list of active builds for this BomItem
|
||||
|
||||
if bom_item.inherited:
|
||||
# An "inherited" BOM item filters down to variant parts also
|
||||
childs = bom_item.part.get_descendants(include_self=True)
|
||||
builds = Build.objects.filter(
|
||||
status__in=BuildStatus.ACTIVE_CODES,
|
||||
part__in=childs,
|
||||
)
|
||||
else:
|
||||
builds = Build.objects.filter(
|
||||
status__in=BuildStatus.ACTIVE_CODES,
|
||||
part=bom_item.part,
|
||||
)
|
||||
|
||||
for build in builds:
|
||||
|
||||
# Ensure we don't double-count any builds
|
||||
if build in seen_builds:
|
||||
continue
|
||||
|
||||
seen_builds.add(build)
|
||||
|
||||
if bom_item.sub_part.trackable:
|
||||
# Trackable parts are allocated against the outputs
|
||||
required_quantity = build.remaining * bom_item.quantity
|
||||
else:
|
||||
# Non-trackable parts are allocated against the build itself
|
||||
required_quantity = build.quantity * bom_item.quantity
|
||||
|
||||
# Grab all allocations against the spefied BomItem
|
||||
allocations = BuildItem.objects.filter(
|
||||
bom_item=bom_item,
|
||||
build=build,
|
||||
)
|
||||
|
||||
# Total allocated for *this* part
|
||||
part_allocated_quantity = 0
|
||||
|
||||
# Total allocated for *any* part
|
||||
total_allocated_quantity = 0
|
||||
|
||||
for allocation in allocations:
|
||||
total_allocated_quantity += allocation.quantity
|
||||
|
||||
if allocation.stock_item.part == part:
|
||||
part_allocated_quantity += allocation.quantity
|
||||
|
||||
speculative_quantity = 0
|
||||
|
||||
# Consider the case where the build order is *not* fully allocated
|
||||
if required_quantity > total_allocated_quantity:
|
||||
speculative_quantity = -1 * (required_quantity - total_allocated_quantity)
|
||||
|
||||
add_schedule_entry(
|
||||
build.target_date,
|
||||
-part_allocated_quantity,
|
||||
_('Stock required for Build Order'),
|
||||
str(build),
|
||||
build.get_absolute_url(),
|
||||
speculative_quantity=speculative_quantity
|
||||
)
|
||||
|
||||
def compare(entry_1, entry_2):
|
||||
"""Comparison function for sorting entries by date.
|
||||
|
||||
Account for the fact that either date might be None
|
||||
"""
|
||||
|
||||
date_1 = entry_1['date']
|
||||
date_2 = entry_2['date']
|
||||
|
||||
if date_1 is None:
|
||||
return -1
|
||||
elif date_2 is None:
|
||||
return 1
|
||||
|
||||
return -1 if date_1 < date_2 else 1
|
||||
|
||||
# Sort by incrementing date values
|
||||
schedule = sorted(schedule, key=lambda entry: entry['date'])
|
||||
schedule = sorted(schedule, key=functools.cmp_to_key(compare))
|
||||
|
||||
return Response(schedule)
|
||||
|
||||
@@ -1746,28 +1817,7 @@ class BomList(ListCreateDestroyAPIView):
|
||||
# Extract the part we are interested in
|
||||
uses_part = Part.objects.get(pk=uses)
|
||||
|
||||
# Construct the database query in multiple parts
|
||||
|
||||
# A) Direct specification of sub_part
|
||||
q_A = Q(sub_part=uses_part)
|
||||
|
||||
# B) BomItem is inherited and points to a "parent" of this part
|
||||
parents = uses_part.get_ancestors(include_self=False)
|
||||
|
||||
q_B = Q(
|
||||
inherited=True,
|
||||
sub_part__in=parents
|
||||
)
|
||||
|
||||
# C) Substitution of variant parts
|
||||
# TODO
|
||||
|
||||
# D) Specification of individual substitutes
|
||||
# TODO
|
||||
|
||||
q = q_A | q_B
|
||||
|
||||
queryset = queryset.filter(q)
|
||||
queryset = queryset.filter(uses_part.get_used_in_bom_item_filter())
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.15 on 2022-08-15 08:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0083_auto_20220731_2357'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='partcategory',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, help_text='Icon (optional)', max_length=100, verbose_name='Icon'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.15 on 2022-08-24 12:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0084_partcategory_icon'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='partparametertemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, help_text='Parameter description', max_length=250, verbose_name='Description'),
|
||||
),
|
||||
]
|
||||
@@ -101,6 +101,13 @@ class PartCategory(MetadataMixin, InvenTreeTree):
|
||||
|
||||
default_keywords = models.CharField(null=True, blank=True, max_length=250, verbose_name=_('Default keywords'), help_text=_('Default keywords for parts in this category'))
|
||||
|
||||
icon = models.CharField(
|
||||
blank=True,
|
||||
max_length=100,
|
||||
verbose_name=_("Icon"),
|
||||
help_text=_("Icon (optional)")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API url associated with the PartCategory model"""
|
||||
@@ -1429,6 +1436,45 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
return parts
|
||||
|
||||
def get_used_in_bom_item_filter(self, include_variants=True, include_substitutes=True):
|
||||
"""Return a BomItem queryset which returns all BomItem instances which refer to *this* part.
|
||||
|
||||
As the BOM allocation logic is somewhat complicted, there are some considerations:
|
||||
|
||||
A) This part may be directly specified in a BomItem instance
|
||||
B) This part may be a *variant* of a part which is directly specified in a BomItem instance
|
||||
C) This part may be a *substitute* for a part which is directly specifed in a BomItem instance
|
||||
|
||||
So we construct a query for each case, and combine them...
|
||||
"""
|
||||
|
||||
# Cache all *parent* parts
|
||||
parents = self.get_ancestors(include_self=False)
|
||||
|
||||
# Case A: This part is directly specified in a BomItem (we always use this case)
|
||||
query = Q(
|
||||
sub_part=self,
|
||||
)
|
||||
|
||||
if include_variants:
|
||||
# Case B: This part is a *variant* of a part which is specified in a BomItem which allows variants
|
||||
query |= Q(
|
||||
allow_variants=True,
|
||||
sub_part__in=parents,
|
||||
)
|
||||
|
||||
# Case C: This part is a *substitute* of a part which is directly specified in a BomItem
|
||||
if include_substitutes:
|
||||
|
||||
# Grab a list of BomItem substitutes which reference this part
|
||||
substitutes = self.substitute_items.all()
|
||||
|
||||
query |= Q(
|
||||
pk__in=[substitute.bom_item.pk for substitute in substitutes],
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
def get_used_in_filter(self, include_inherited=True):
|
||||
"""Return a query filter for all parts that this part is used in.
|
||||
|
||||
@@ -2323,7 +2369,7 @@ class PartTestTemplate(models.Model):
|
||||
|
||||
def validate_template_name(name):
|
||||
"""Prevent illegal characters in "name" field for PartParameterTemplate."""
|
||||
for c in "!@#$%^&*()<>{}[].,?/\\|~`_+-=\'\"": # noqa: P103
|
||||
for c in "\"\'`!?|": # noqa: P103
|
||||
if c in str(name):
|
||||
raise ValidationError(_(f"Illegal character in template name ({c})"))
|
||||
|
||||
@@ -2378,6 +2424,13 @@ class PartParameterTemplate(models.Model):
|
||||
|
||||
units = models.CharField(max_length=25, verbose_name=_('Units'), help_text=_('Parameter Units'), blank=True)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=250,
|
||||
verbose_name=_('Description'),
|
||||
help_text=_('Parameter description'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
|
||||
class PartParameter(models.Model):
|
||||
"""A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part.
|
||||
|
||||
@@ -75,6 +75,7 @@ class CategorySerializer(InvenTreeModelSerializer):
|
||||
'pathstring',
|
||||
'starred',
|
||||
'url',
|
||||
'icon',
|
||||
]
|
||||
|
||||
|
||||
@@ -88,6 +89,7 @@ class CategoryTree(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'name',
|
||||
'parent',
|
||||
'icon',
|
||||
]
|
||||
|
||||
|
||||
@@ -238,6 +240,7 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'name',
|
||||
'units',
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "part/part_app_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'part/category_sidebar.html' %}
|
||||
@@ -12,7 +13,12 @@
|
||||
|
||||
{% block heading %}
|
||||
{% if category %}
|
||||
{% trans "Part Category" %}: {{ category.name }}
|
||||
{% trans "Part Category" %}:
|
||||
{% settings_value "PART_CATEGORY_DEFAULT_ICON" as default_icon %}
|
||||
{% if category.icon or default_icon %}
|
||||
<span class="{{ category.icon|default:default_icon }}"></span>
|
||||
{% endif %}
|
||||
{{ category.name }}
|
||||
{% else %}
|
||||
{% trans "Parts" %}
|
||||
{% endif %}
|
||||
@@ -288,7 +294,8 @@
|
||||
node.href = `/part/category/${node.pk}/`;
|
||||
|
||||
return node;
|
||||
}
|
||||
},
|
||||
defaultIcon: global_settings.PART_CATEGORY_DEFAULT_ICON,
|
||||
});
|
||||
|
||||
onPanelLoad('subcategories', function() {
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Part Scheduling" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
<button class='btn btn-primary' type='button' id='btn-schedule-reload' title='{% trans "Refresh scheduling data" %}'>
|
||||
<span class='fas fa-redo-alt'></span> {% trans "Refresh" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
@@ -427,7 +432,16 @@
|
||||
|
||||
// Load the "scheduling" tab
|
||||
onPanelLoad('scheduling', function() {
|
||||
loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
|
||||
var chart = loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
|
||||
|
||||
$('#btn-schedule-reload').click(function() {
|
||||
|
||||
if (chart != null) {
|
||||
chart.destroy();
|
||||
}
|
||||
|
||||
chart = loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
|
||||
});
|
||||
});
|
||||
|
||||
// Load the "suppliers" tab
|
||||
|
||||
@@ -4,3 +4,15 @@
|
||||
<div id='part-schedule' style='max-height: 300px;'>
|
||||
<canvas id='part-schedule-chart' width='100%' style='max-height: 300px;'></canvas>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='part-schedule-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Link" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Scheduled Quantity" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
@@ -17,6 +17,7 @@ import InvenTree.helpers
|
||||
from common.models import ColorTheme, InvenTreeSetting, InvenTreeUserSetting
|
||||
from common.settings import currency_code_default
|
||||
from InvenTree import settings, version
|
||||
from plugin import registry
|
||||
from plugin.models import NotificationUserSetting, PluginSetting
|
||||
|
||||
register = template.Library()
|
||||
@@ -149,6 +150,25 @@ def plugins_enabled(*args, **kwargs):
|
||||
return djangosettings.PLUGINS_ENABLED
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def plugins_info(*args, **kwargs):
|
||||
"""Return information about activated plugins."""
|
||||
# Check if plugins are even enabled
|
||||
if not djangosettings.PLUGINS_ENABLED:
|
||||
return False
|
||||
|
||||
# Fetch plugins
|
||||
plug_list = [plg for plg in registry.plugins.values() if plg.plugin_config().active]
|
||||
# Format list
|
||||
return [
|
||||
{
|
||||
'name': plg.name,
|
||||
'slug': plg.slug,
|
||||
'version': plg.version
|
||||
} for plg in plug_list
|
||||
]
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_db_engine(*args, **kwargs):
|
||||
"""Return the InvenTree database backend e.g. 'postgresql'."""
|
||||
@@ -175,7 +195,7 @@ def inventree_title(*args, **kwargs):
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_logo(**kwargs):
|
||||
"""Return the InvenTree logo, *or* a custom logo if the user has uploaded one.
|
||||
"""Return the InvenTree logo, *or* a custom logo if the user has provided one.
|
||||
|
||||
Returns a path to an image file, which can be rendered in the web interface
|
||||
"""
|
||||
@@ -183,6 +203,13 @@ def inventree_logo(**kwargs):
|
||||
return InvenTree.helpers.getLogoImage(**kwargs)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_splash(**kwargs):
|
||||
"""Return the URL for the InvenTree splash screen, *or* a custom screen if the user has provided one."""
|
||||
|
||||
return InvenTree.helpers.getSplashScren(**kwargs)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_base_url(*args, **kwargs):
|
||||
"""Return the INVENTREE_BASE_URL setting."""
|
||||
|
||||
+59
-21
@@ -223,35 +223,73 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(PartCategoryParameterTemplate.objects.count(), 0)
|
||||
|
||||
def test_bleach(self):
|
||||
"""Test that the data cleaning functionality is working"""
|
||||
"""Test that the data cleaning functionality is working.
|
||||
|
||||
This helps to protect against XSS injection
|
||||
"""
|
||||
|
||||
url = reverse('api-part-category-detail', kwargs={'pk': 1})
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'description': '<img src=# onerror=alert("pwned")>',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
# Invalid values containing tags
|
||||
invalid_values = [
|
||||
'<img src="test"/>',
|
||||
'<a href="#">Link</a>',
|
||||
"<a href='#'>Link</a>",
|
||||
'<b>',
|
||||
]
|
||||
|
||||
cat = PartCategory.objects.get(pk=1)
|
||||
for v in invalid_values:
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'description': v
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
# Image tags have been stripped
|
||||
self.assertEqual(cat.description, '<img src=# onerror=alert("pwned")>')
|
||||
self.assertIn('Remove HTML tags', str(response.data))
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'description': '<a href="www.google.com">LINK</a><script>alert("h4x0r")</script>',
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
# Raw characters should be allowed
|
||||
allowed = [
|
||||
'<< hello',
|
||||
'Alpha & Omega',
|
||||
'A > B > C',
|
||||
]
|
||||
|
||||
# Tags must have been bleached out
|
||||
cat.refresh_from_db()
|
||||
for val in allowed:
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'description': val,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(cat.description, '<a href="www.google.com">LINK</a><script>alert("h4x0r")</script>')
|
||||
self.assertEqual(response.data['description'], val)
|
||||
|
||||
def test_invisible_chars(self):
|
||||
"""Test that invisible characters are removed from the input data"""
|
||||
|
||||
url = reverse('api-part-category-detail', kwargs={'pk': 1})
|
||||
|
||||
values = [
|
||||
'A part\n category\n\t',
|
||||
'A\t part\t category\t',
|
||||
'A pa\rrt cat\r\r\regory',
|
||||
'A part\u200e catego\u200fry\u202e'
|
||||
]
|
||||
|
||||
for val in values:
|
||||
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'description': val,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['description'], 'A part category')
|
||||
|
||||
|
||||
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
|
||||
@@ -28,9 +28,6 @@ part_detail_urls = [
|
||||
|
||||
category_urls = [
|
||||
|
||||
# Top level subcategory display
|
||||
re_path(r'^subcategory/', views.PartIndex.as_view(template_name='part/subcategory.html'), name='category-index-subcategory'),
|
||||
|
||||
# Category detail views
|
||||
re_path(r'(?P<pk>\d+)/', views.CategoryDetail.as_view(), name='category-detail'),
|
||||
]
|
||||
|
||||
@@ -52,7 +52,7 @@ class PluginConfigAdmin(admin.ModelAdmin):
|
||||
"""Custom admin with restricted id fields."""
|
||||
|
||||
readonly_fields = ["key", "name", ]
|
||||
list_display = ['name', 'key', '__str__', 'active', ]
|
||||
list_display = ['name', 'key', '__str__', 'active', 'is_sample']
|
||||
list_filter = ['active']
|
||||
actions = [plugin_activate, plugin_deactivate, ]
|
||||
inlines = [PluginSettingInline, ]
|
||||
|
||||
@@ -43,7 +43,7 @@ class PluginAppConfig(AppConfig):
|
||||
pass
|
||||
|
||||
# get plugins and init them
|
||||
registry.collect_plugins()
|
||||
registry.plugin_modules = registry.collect_plugins()
|
||||
registry.load_plugins()
|
||||
|
||||
# drop out of maintenance
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Core set of Notifications as a Plugin."""
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
|
||||
+19
-10
@@ -7,6 +7,7 @@ import pkgutil
|
||||
import subprocess
|
||||
import sysconfig
|
||||
import traceback
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
@@ -68,18 +69,21 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st
|
||||
package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
|
||||
except ValueError:
|
||||
# is file - loaded -> form a name for that
|
||||
path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
|
||||
path_parts = [*path_obj.parts]
|
||||
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
|
||||
try:
|
||||
path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
|
||||
path_parts = [*path_obj.parts]
|
||||
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
|
||||
|
||||
# remove path prefixes
|
||||
if path_parts[0] == 'plugin':
|
||||
path_parts.remove('plugin')
|
||||
path_parts.pop(0)
|
||||
else:
|
||||
path_parts.remove('plugins') # pragma: no cover
|
||||
# remove path prefixes
|
||||
if path_parts[0] == 'plugin':
|
||||
path_parts.remove('plugin')
|
||||
path_parts.pop(0)
|
||||
else:
|
||||
path_parts.remove('plugins') # pragma: no cover
|
||||
|
||||
package_name = '.'.join(path_parts)
|
||||
package_name = '.'.join(path_parts)
|
||||
except Exception:
|
||||
package_name = package_path
|
||||
|
||||
if do_log:
|
||||
log_kwargs = {}
|
||||
@@ -92,6 +96,11 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st
|
||||
if settings.TESTING_ENV and package_name != 'integration.broken_sample' and isinstance(error, IntegrityError):
|
||||
raise error # pragma: no cover
|
||||
raise IntegrationPluginError(package_name, str(error))
|
||||
|
||||
|
||||
def get_entrypoints():
|
||||
"""Returns list for entrypoints for InvenTree plugins."""
|
||||
return entry_points().get('inventree_plugins', [])
|
||||
# endregion
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Very simple sample plugin"""
|
||||
|
||||
from plugin import InvenTreePlugin
|
||||
|
||||
|
||||
class SimplePlugin(InvenTreePlugin):
|
||||
"""A very simple plugin."""
|
||||
|
||||
NAME = 'SimplePlugin'
|
||||
SLUG = "simple"
|
||||
@@ -3,6 +3,7 @@
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -123,11 +124,11 @@ class PluginConfig(models.Model):
|
||||
self.__org_active = self.active
|
||||
|
||||
# append settings from registry
|
||||
self.plugin = registry.plugins.get(self.key, None)
|
||||
plugin = registry.plugins_full.get(self.key, None)
|
||||
|
||||
def get_plugin_meta(name):
|
||||
if self.plugin:
|
||||
return str(getattr(self.plugin, name, None))
|
||||
if plugin:
|
||||
return str(getattr(plugin, name, None))
|
||||
return None
|
||||
|
||||
self.meta = {
|
||||
@@ -136,6 +137,9 @@ class PluginConfig(models.Model):
|
||||
'package_path', 'settings_url', ]
|
||||
}
|
||||
|
||||
# Save plugin
|
||||
self.plugin: InvenTreePlugin = plugin
|
||||
|
||||
def save(self, force_insert=False, force_update=False, *args, **kwargs):
|
||||
"""Extend save method to reload plugins if the 'active' status changes."""
|
||||
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||
@@ -151,10 +155,26 @@ class PluginConfig(models.Model):
|
||||
|
||||
return ret
|
||||
|
||||
@admin.display(boolean=True, description=_('Sample plugin'))
|
||||
def is_sample(self) -> bool:
|
||||
"""Is this plugin a sample app?"""
|
||||
# Loaded and active plugin
|
||||
if isinstance(self.plugin, InvenTreePlugin):
|
||||
return self.plugin.check_is_sample()
|
||||
|
||||
# If no plugin_class is available it can not be a sample
|
||||
if not self.plugin:
|
||||
return False
|
||||
|
||||
# Not loaded plugin
|
||||
return self.plugin.check_is_sample() # pragma: no cover
|
||||
|
||||
|
||||
class PluginSetting(common.models.BaseInvenTreeSetting):
|
||||
"""This model represents settings for individual plugins."""
|
||||
|
||||
typ = 'plugin'
|
||||
|
||||
class Meta:
|
||||
"""Meta for PluginSetting."""
|
||||
unique_together = [
|
||||
@@ -204,6 +224,8 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
|
||||
class NotificationUserSetting(common.models.BaseInvenTreeSetting):
|
||||
"""This model represents notification settings for a user."""
|
||||
|
||||
typ = 'notification'
|
||||
|
||||
class Meta:
|
||||
"""Meta for NotificationUserSetting."""
|
||||
unique_together = [
|
||||
|
||||
+73
-25
@@ -2,11 +2,11 @@
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
from importlib.metadata import metadata
|
||||
from distutils.sysconfig import get_python_lib
|
||||
from importlib.metadata import PackageNotFoundError, metadata
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
@@ -171,7 +171,24 @@ class MixinBase:
|
||||
return mixins
|
||||
|
||||
|
||||
class InvenTreePlugin(MixinBase, MetaBase):
|
||||
class VersionMixin:
|
||||
"""Mixin to enable version checking."""
|
||||
|
||||
MIN_VERSION = None
|
||||
MAX_VERSION = None
|
||||
|
||||
def check_version(self, latest=None) -> bool:
|
||||
"""Check if plugin functions for the current InvenTree version."""
|
||||
from InvenTree import version
|
||||
|
||||
latest = latest if latest else version.inventreeVersionTuple()
|
||||
min_v = version.inventreeVersionTuple(self.MIN_VERSION)
|
||||
max_v = version.inventreeVersionTuple(self.MAX_VERSION)
|
||||
|
||||
return bool(min_v <= latest <= max_v)
|
||||
|
||||
|
||||
class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
"""The InvenTreePlugin class is used to integrate with 3rd party software.
|
||||
|
||||
DO NOT USE THIS DIRECTLY, USE plugin.InvenTreePlugin
|
||||
@@ -191,11 +208,18 @@ class InvenTreePlugin(MixinBase, MetaBase):
|
||||
"""
|
||||
super().__init__()
|
||||
self.add_mixin('base')
|
||||
self.def_path = inspect.getfile(self.__class__)
|
||||
self.path = os.path.dirname(self.def_path)
|
||||
|
||||
self.define_package()
|
||||
|
||||
@classmethod
|
||||
def file(cls) -> Path:
|
||||
"""File that contains plugin definition."""
|
||||
return Path(inspect.getfile(cls))
|
||||
|
||||
def path(self) -> Path:
|
||||
"""Path to plugins base folder."""
|
||||
return self.file().parent
|
||||
|
||||
def _get_value(self, meta_name: str, package_name: str) -> str:
|
||||
"""Extract values from class meta or package info.
|
||||
|
||||
@@ -243,43 +267,54 @@ class InvenTreePlugin(MixinBase, MetaBase):
|
||||
@property
|
||||
def version(self):
|
||||
"""Version of plugin."""
|
||||
version = self._get_value('VERSION', 'version')
|
||||
return version
|
||||
return self._get_value('VERSION', 'version')
|
||||
|
||||
@property
|
||||
def website(self):
|
||||
"""Website of plugin - if set else None."""
|
||||
website = self._get_value('WEBSITE', 'website')
|
||||
return website
|
||||
return self._get_value('WEBSITE', 'website')
|
||||
|
||||
@property
|
||||
def license(self):
|
||||
"""License of plugin."""
|
||||
lic = self._get_value('LICENSE', 'license')
|
||||
return lic
|
||||
return self._get_value('LICENSE', 'license')
|
||||
# endregion
|
||||
|
||||
@classmethod
|
||||
def check_is_package(cls):
|
||||
"""Is the plugin delivered as a package."""
|
||||
return getattr(cls, 'is_package', False)
|
||||
|
||||
@property
|
||||
def _is_package(self):
|
||||
"""Is the plugin delivered as a package."""
|
||||
return getattr(self, 'is_package', False)
|
||||
|
||||
@property
|
||||
def is_sample(self):
|
||||
@classmethod
|
||||
def check_is_sample(cls) -> bool:
|
||||
"""Is this plugin part of the samples?"""
|
||||
path = str(self.package_path)
|
||||
return path.startswith('plugin/samples/')
|
||||
return str(cls.check_package_path()).startswith('plugin/samples/')
|
||||
|
||||
@property
|
||||
def is_sample(self) -> bool:
|
||||
"""Is this plugin part of the samples?"""
|
||||
return self.check_is_sample()
|
||||
|
||||
@classmethod
|
||||
def check_package_path(cls):
|
||||
"""Path to the plugin."""
|
||||
if cls.check_is_package():
|
||||
return cls.__module__ # pragma: no cover
|
||||
|
||||
try:
|
||||
return cls.file().relative_to(settings.BASE_DIR)
|
||||
except ValueError:
|
||||
return cls.file()
|
||||
|
||||
@property
|
||||
def package_path(self):
|
||||
"""Path to the plugin."""
|
||||
if self._is_package:
|
||||
return self.__module__ # pragma: no cover
|
||||
|
||||
try:
|
||||
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
|
||||
except ValueError:
|
||||
return pathlib.Path(self.def_path)
|
||||
return self.check_package_path()
|
||||
|
||||
@property
|
||||
def settings_url(self):
|
||||
@@ -289,12 +324,25 @@ class InvenTreePlugin(MixinBase, MetaBase):
|
||||
# region package info
|
||||
def _get_package_commit(self):
|
||||
"""Get last git commit for the plugin."""
|
||||
return get_git_log(self.def_path)
|
||||
return get_git_log(str(self.file()))
|
||||
|
||||
@classmethod
|
||||
def is_editable(cls):
|
||||
"""Returns if the current part is editable."""
|
||||
pkg_name = cls.__name__.split('.')[0]
|
||||
dist_info = list(Path(get_python_lib()).glob(f'{pkg_name}-*.dist-info'))
|
||||
return bool(len(dist_info) == 1)
|
||||
|
||||
@classmethod
|
||||
def _get_package_metadata(cls):
|
||||
"""Get package metadata for plugin."""
|
||||
meta = metadata(cls.__name__)
|
||||
|
||||
# Try simple metadata lookup
|
||||
try:
|
||||
meta = metadata(cls.__name__)
|
||||
# Simpel lookup did not work - get data from module
|
||||
except PackageNotFoundError:
|
||||
meta = metadata(cls.__module__.split('.')[0])
|
||||
|
||||
return {
|
||||
'author': meta['Author-email'],
|
||||
|
||||
+109
-89
@@ -4,13 +4,14 @@
|
||||
- Manages setup and teardown of plugin class instances
|
||||
"""
|
||||
|
||||
import imp
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from importlib import metadata, reload
|
||||
from importlib import reload
|
||||
from pathlib import Path
|
||||
from typing import OrderedDict
|
||||
from typing import Dict, List, OrderedDict
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
@@ -24,8 +25,8 @@ from maintenance_mode.core import (get_maintenance_mode, maintenance_mode_on,
|
||||
|
||||
from InvenTree.config import get_setting
|
||||
|
||||
from .helpers import (IntegrationPluginError, get_plugins, handle_error,
|
||||
log_error)
|
||||
from .helpers import (IntegrationPluginError, get_entrypoints, get_plugins,
|
||||
handle_error, log_error)
|
||||
from .plugin import InvenTreePlugin
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@@ -40,19 +41,20 @@ class PluginsRegistry:
|
||||
Set up all needed references for internal and external states.
|
||||
"""
|
||||
# plugin registry
|
||||
self.plugins = {}
|
||||
self.plugins_inactive = {}
|
||||
self.plugins: Dict[str, InvenTreePlugin] = {} # List of active instances
|
||||
self.plugins_inactive: Dict[str, InvenTreePlugin] = {} # List of inactive instances
|
||||
self.plugins_full: Dict[str, InvenTreePlugin] = {} # List of all plugin instances
|
||||
|
||||
self.plugin_modules = [] # Holds all discovered plugins
|
||||
self.plugin_modules: List(InvenTreePlugin) = [] # Holds all discovered plugins
|
||||
|
||||
self.errors = {} # Holds discovering errors
|
||||
self.errors = {} # Holds discovering errors
|
||||
|
||||
# flags
|
||||
self.is_loading = False
|
||||
self.apps_loading = True # Marks if apps were reloaded yet
|
||||
self.git_is_modern = True # Is a modern version of git available
|
||||
self.is_loading = False # Are plugins beeing loaded right now
|
||||
self.apps_loading = True # Marks if apps were reloaded yet
|
||||
self.git_is_modern = True # Is a modern version of git available
|
||||
|
||||
self.installed_apps = [] # Holds all added plugin_paths
|
||||
self.installed_apps = [] # Holds all added plugin_paths
|
||||
|
||||
# mixins
|
||||
self.mixins_settings = {}
|
||||
@@ -200,7 +202,7 @@ class PluginsRegistry:
|
||||
|
||||
if settings.TESTING:
|
||||
custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None)
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
custom_dirs = get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir')
|
||||
|
||||
# Load from user specified directories (unless in testing mode)
|
||||
@@ -215,7 +217,7 @@ class PluginsRegistry:
|
||||
if not pd.exists():
|
||||
try:
|
||||
pd.mkdir(exist_ok=True)
|
||||
except Exception:
|
||||
except Exception: # pragma: no cover
|
||||
logger.error(f"Could not create plugin directory '{pd}'")
|
||||
continue
|
||||
|
||||
@@ -225,24 +227,31 @@ class PluginsRegistry:
|
||||
if not init_filename.exists():
|
||||
try:
|
||||
init_filename.write_text("# InvenTree plugin directory\n")
|
||||
except Exception:
|
||||
except Exception: # pragma: no cover
|
||||
logger.error(f"Could not create file '{init_filename}'")
|
||||
continue
|
||||
|
||||
# By this point, we have confirmed that the directory at least exists
|
||||
if pd.exists() and pd.is_dir():
|
||||
# By this point, we have confirmed that the directory at least exists
|
||||
logger.info(f"Added plugin directory: '{pd}'")
|
||||
dirs.append(pd)
|
||||
# Convert to python dot-path
|
||||
if pd.is_relative_to(settings.BASE_DIR):
|
||||
pd_path = '.'.join(pd.relative_to(settings.BASE_DIR).parts)
|
||||
else:
|
||||
pd_path = str(pd)
|
||||
|
||||
# Add path
|
||||
dirs.append(pd_path)
|
||||
logger.info(f"Added plugin directory: '{pd}' as '{pd_path}'")
|
||||
|
||||
return dirs
|
||||
|
||||
def collect_plugins(self):
|
||||
"""Collect plugins from all possible ways of loading."""
|
||||
"""Collect plugins from all possible ways of loading. Returned as list."""
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
# Plugins not enabled, do nothing
|
||||
return # pragma: no cover
|
||||
|
||||
self.plugin_modules = [] # clear
|
||||
collected_plugins = []
|
||||
|
||||
# Collect plugins from paths
|
||||
for plugin in self.plugin_dirs():
|
||||
@@ -258,26 +267,33 @@ class PluginsRegistry:
|
||||
parent_path = str(parent_obj.parent)
|
||||
plugin = parent_obj.name
|
||||
|
||||
modules = get_plugins(importlib.import_module(plugin), InvenTreePlugin, path=parent_path)
|
||||
# Gather Modules
|
||||
if parent_path:
|
||||
raw_module = imp.load_source(plugin, str(parent_obj.joinpath('__init__.py')))
|
||||
else:
|
||||
raw_module = importlib.import_module(plugin)
|
||||
modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path)
|
||||
|
||||
if modules:
|
||||
[self.plugin_modules.append(item) for item in modules]
|
||||
[collected_plugins.append(item) for item in modules]
|
||||
|
||||
# Check if not running in testing mode and apps should be loaded from hooks
|
||||
if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP):
|
||||
# Collect plugins from setup entry points
|
||||
for entry in metadata.entry_points().get('inventree_plugins', []): # pragma: no cover
|
||||
for entry in get_entrypoints():
|
||||
try:
|
||||
plugin = entry.load()
|
||||
plugin.is_package = True
|
||||
plugin._get_package_metadata()
|
||||
self.plugin_modules.append(plugin)
|
||||
except Exception as error:
|
||||
collected_plugins.append(plugin)
|
||||
except Exception as error: # pragma: no cover
|
||||
handle_error(error, do_raise=False, log_name='discovery')
|
||||
|
||||
# Log collected plugins
|
||||
logger.info(f'Collected {len(self.plugin_modules)} plugins!')
|
||||
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
|
||||
logger.info(f'Collected {len(collected_plugins)} plugins!')
|
||||
logger.info(", ".join([a.__module__ for a in collected_plugins]))
|
||||
|
||||
return collected_plugins
|
||||
|
||||
def install_plugin_file(self):
|
||||
"""Make sure all plugins are installed in the current enviroment."""
|
||||
@@ -324,74 +340,77 @@ class PluginsRegistry:
|
||||
# endregion
|
||||
|
||||
# region general internal loading /activating / deactivating / deloading
|
||||
def _init_plugins(self, disabled=None):
|
||||
def _init_plugins(self, disabled: str = None):
|
||||
"""Initialise all found plugins.
|
||||
|
||||
:param disabled: loading path of disabled app, defaults to None
|
||||
:type disabled: str, optional
|
||||
:raises error: IntegrationPluginError
|
||||
Args:
|
||||
disabled (str, optional): Loading path of disabled app. Defaults to None.
|
||||
|
||||
Raises:
|
||||
error: IntegrationPluginError
|
||||
"""
|
||||
from plugin.models import PluginConfig
|
||||
|
||||
def safe_reference(plugin, key: str, active: bool = True):
|
||||
"""Safe reference to plugin dicts."""
|
||||
if active:
|
||||
self.plugins[key] = plugin
|
||||
else:
|
||||
# Deactivate plugin in db
|
||||
if not settings.PLUGIN_TESTING: # pragma: no cover
|
||||
plugin.db.active = False
|
||||
plugin.db.save(no_reload=True)
|
||||
self.plugins_inactive[key] = plugin.db
|
||||
self.plugins_full[key] = plugin
|
||||
|
||||
logger.info('Starting plugin initialisation')
|
||||
|
||||
# Initialize plugins
|
||||
for plugin in self.plugin_modules:
|
||||
# Check if package
|
||||
was_packaged = getattr(plugin, 'is_package', False)
|
||||
|
||||
# Check if activated
|
||||
for plg in self.plugin_modules:
|
||||
# These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
|
||||
plug_name = plugin.NAME
|
||||
plug_key = plugin.SLUG if getattr(plugin, 'SLUG', None) else plug_name
|
||||
plug_key = slugify(plug_key) # keys are slugs!
|
||||
plg_name = plg.NAME
|
||||
plg_key = slugify(plg.SLUG if getattr(plg, 'SLUG', None) else plg_name) # keys are slugs!
|
||||
|
||||
try:
|
||||
plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
|
||||
plg_db, _ = PluginConfig.objects.get_or_create(key=plg_key, name=plg_name)
|
||||
except (OperationalError, ProgrammingError) as error:
|
||||
# Exception if the database has not been migrated yet - check if test are running - raise if not
|
||||
if not settings.PLUGIN_TESTING:
|
||||
raise error # pragma: no cover
|
||||
plugin_db_setting = None
|
||||
plg_db = None
|
||||
except (IntegrityError) as error: # pragma: no cover
|
||||
logger.error(f"Error initializing plugin: {error}")
|
||||
logger.error(f"Error initializing plugin `{plg_name}`: {error}")
|
||||
|
||||
# Append reference to plugin
|
||||
plg.db = plg_db
|
||||
|
||||
# Always activate if testing
|
||||
if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
|
||||
# Check if the plugin was blocked -> threw an error
|
||||
if disabled:
|
||||
# option1: package, option2: file-based
|
||||
if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
|
||||
# Errors are bad so disable the plugin in the database
|
||||
if not settings.PLUGIN_TESTING: # pragma: no cover
|
||||
plugin_db_setting.active = False
|
||||
plugin_db_setting.save(no_reload=True)
|
||||
|
||||
# Add to inactive plugins so it shows up in the ui
|
||||
self.plugins_inactive[plug_key] = plugin_db_setting
|
||||
continue # continue -> the plugin is not loaded
|
||||
|
||||
# Initialize package
|
||||
# now we can be sure that an admin has activated the plugin
|
||||
logger.info(f'Loading plugin {plug_name}')
|
||||
if settings.PLUGIN_TESTING or (plg_db and plg_db.active):
|
||||
# Check if the plugin was blocked -> threw an error; option1: package, option2: file-based
|
||||
if disabled and ((plg.__name__ == disabled) or (plg.__module__ == disabled)):
|
||||
safe_reference(plugin=plg, key=plg_key, active=False)
|
||||
continue # continue -> the plugin is not loaded
|
||||
|
||||
# Initialize package - we can be sure that an admin has activated the plugin
|
||||
logger.info(f'Loading plugin `{plg_name}`')
|
||||
try:
|
||||
plugin = plugin()
|
||||
plg_i: InvenTreePlugin = plg()
|
||||
logger.info(f'Loaded plugin `{plg_name}`')
|
||||
except Exception as error:
|
||||
# log error and raise it -> disable plugin
|
||||
handle_error(error, log_name='init')
|
||||
handle_error(error, log_name='init') # log error and raise it -> disable plugin
|
||||
|
||||
logger.debug(f'Loaded plugin {plug_name}')
|
||||
# Safe extra attributes
|
||||
plg_i.is_package = getattr(plg_i, 'is_package', False)
|
||||
plg_i.pk = plg_db.pk if plg_db else None
|
||||
plg_i.db = plg_db
|
||||
|
||||
plugin.is_package = was_packaged
|
||||
|
||||
if plugin_db_setting:
|
||||
plugin.pk = plugin_db_setting.pk
|
||||
|
||||
# safe reference
|
||||
self.plugins[plugin.slug] = plugin
|
||||
else:
|
||||
# save for later reference
|
||||
self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover
|
||||
# Run version check for plugin
|
||||
if (plg_i.MIN_VERSION or plg_i.MAX_VERSION) and not plg_i.check_version():
|
||||
safe_reference(plugin=plg_i, key=plg_key, active=False)
|
||||
else:
|
||||
safe_reference(plugin=plg_i, key=plg_key)
|
||||
else: # pragma: no cover
|
||||
safe_reference(plugin=plg, key=plg_key, active=False)
|
||||
|
||||
def _activate_plugins(self, force_reload=False, full_reload: bool = False):
|
||||
"""Run activation functions for all plugins.
|
||||
@@ -568,10 +587,10 @@ class PluginsRegistry:
|
||||
"""
|
||||
try:
|
||||
# for local path plugins
|
||||
plugin_path = '.'.join(Path(plugin.path).relative_to(settings.BASE_DIR).parts)
|
||||
plugin_path = '.'.join(plugin.path().relative_to(settings.BASE_DIR).parts)
|
||||
except ValueError: # pragma: no cover
|
||||
# plugin is shipped as package
|
||||
plugin_path = plugin.NAME
|
||||
# plugin is shipped as package - extract plugin module name
|
||||
plugin_path = plugin.__module__.split('.')[0]
|
||||
return plugin_path
|
||||
|
||||
def deactivate_plugin_app(self):
|
||||
@@ -625,24 +644,25 @@ class PluginsRegistry:
|
||||
self.installed_apps = []
|
||||
|
||||
def _clean_registry(self):
|
||||
# remove all plugins from registry
|
||||
self.plugins = {}
|
||||
self.plugins_inactive = {}
|
||||
"""Remove all plugins from registry."""
|
||||
self.plugins: Dict[str, InvenTreePlugin] = {}
|
||||
self.plugins_inactive: Dict[str, InvenTreePlugin] = {}
|
||||
self.plugins_full: Dict[str, InvenTreePlugin] = {}
|
||||
|
||||
def _update_urls(self):
|
||||
from InvenTree.urls import frontendpatterns as urlpatterns
|
||||
from InvenTree.urls import frontendpatterns as urlpattern
|
||||
from InvenTree.urls import urlpatterns as global_pattern
|
||||
from plugin.urls import get_plugin_urls
|
||||
|
||||
for index, a in enumerate(urlpatterns):
|
||||
if hasattr(a, 'app_name'):
|
||||
if a.app_name == 'admin':
|
||||
urlpatterns[index] = re_path(r'^admin/', admin.site.urls, name='inventree-admin')
|
||||
elif a.app_name == 'plugin':
|
||||
urlpatterns[index] = get_plugin_urls()
|
||||
for index, url in enumerate(urlpattern):
|
||||
if hasattr(url, 'app_name'):
|
||||
if url.app_name == 'admin':
|
||||
urlpattern[index] = re_path(r'^admin/', admin.site.urls, name='inventree-admin')
|
||||
elif url.app_name == 'plugin':
|
||||
urlpattern[index] = get_plugin_urls()
|
||||
|
||||
# replace frontendpatterns
|
||||
global_pattern[0] = re_path('', include(urlpatterns))
|
||||
# Replace frontendpatterns
|
||||
global_pattern[0] = re_path('', include(urlpattern))
|
||||
clear_url_caches()
|
||||
|
||||
def _reload_apps(self, force_reload: bool = False, full_reload: bool = False):
|
||||
@@ -678,7 +698,7 @@ class PluginsRegistry:
|
||||
# endregion
|
||||
|
||||
|
||||
registry = PluginsRegistry()
|
||||
registry: PluginsRegistry = PluginsRegistry()
|
||||
|
||||
|
||||
def call_function(plugin_name, function_name, *args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Sample plugin for versioning."""
|
||||
from plugin import InvenTreePlugin
|
||||
|
||||
|
||||
class VersionPlugin(InvenTreePlugin):
|
||||
"""A small version sample."""
|
||||
|
||||
NAME = "version"
|
||||
MAX_VERSION = '0.1.0'
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Load templates for loaded plugins."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.template.loaders.filesystem import Loader as FilesystemLoader
|
||||
|
||||
from plugin import registry
|
||||
@@ -26,8 +24,8 @@ class PluginTemplateLoader(FilesystemLoader):
|
||||
template_dirs = []
|
||||
|
||||
for plugin in registry.plugins.values():
|
||||
new_path = Path(plugin.path) / dirname
|
||||
if Path(new_path).is_dir():
|
||||
new_path = plugin.path().joinpath(dirname)
|
||||
if new_path.is_dir():
|
||||
template_dirs.append(new_path)
|
||||
|
||||
return tuple(template_dirs)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user