mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
da3fe7bc30
47
.devcontainer/Dockerfile
Normal file
47
.devcontainer/Dockerfile
Normal file
@ -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
|
87
.devcontainer/devcontainer.json
Normal file
87
.devcontainer/devcontainer.json
Normal file
@ -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}"
|
||||
}
|
||||
}
|
14
.devcontainer/postCreateCommand.sh
Executable file
14
.devcontainer/postCreateCommand.sh
Executable file
@ -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
|
47
.github/ISSUE_TEMPLATE/bug_report.md
vendored
47
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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"
|
||||
--->
|
62
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
62
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
@ -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
|
26
.github/ISSUE_TEMPLATE/feature_request.md
vendored
26
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -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).
|
53
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
@ -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
|
3
.github/workflows/check_translations.yaml
vendored
3
.github/workflows/check_translations.yaml
vendored
@ -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
|
||||
|
26
.github/workflows/docker.yaml
vendored
26
.github/workflows/docker.yaml
vendored
@ -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 }}
|
||||
|
57
.github/workflows/qc_checks.yaml
vendored
57
.github/workflows/qc_checks.yaml
vendored
@ -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:
|
||||
|
48
.github/workflows/release.yml
vendored
48
.github/workflows/release.yml
vendored
@ -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 }}"
|
||||
|
21
.github/workflows/stale.yml
vendored
21
.github/workflows/stale.yml
vendored
@ -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
|
||||
|
14
.github/workflows/translations.yml
vendored
14
.github/workflows/translations.yml
vendored
@ -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
|
||||
|
9
.github/workflows/update.yml
vendored
9
.github/workflows/update.yml
vendored
@ -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
|
||||
|
24
.github/workflows/welcome.yml
vendored
24
.github/workflows/welcome.yml
vendored
@ -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.
|
||||
|
9
.gitignore
vendored
9
.gitignore
vendored
@ -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/
|
||||
|
26
.vscode/launch.json
vendored
Normal file
26
.vscode/launch.json
vendored
Normal file
@ -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
|
||||
}
|
||||
]
|
||||
}
|
52
.vscode/tasks.json
vendored
Normal file
52
.vscode/tasks.json
vendored
Normal file
@ -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",
|
||||
},
|
||||
]
|
||||
}
|
@ -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)
|
||||
|
@ -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."""
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
18
InvenTree/part/migrations/0084_partcategory_icon.py
Normal file
18
InvenTree/part/migrations/0084_partcategory_icon.py
Normal file
@ -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."""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
InvenTree/plugin/mock/__init__.py
Normal file
0
InvenTree/plugin/mock/__init__.py
Normal file
10
InvenTree/plugin/mock/simple.py
Normal file
10
InvenTree/plugin/mock/simple.py
Normal file
@ -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 = [
|
||||
|
@ -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'],
|
||||
|
@ -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):
|
||||
|
9
InvenTree/plugin/samples/integration/version.py
Normal file
9
InvenTree/plugin/samples/integration/version.py
Normal file
@ -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
Loading…
Reference in New Issue
Block a user