Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-09-05 20:07:58 +10:00
commit da3fe7bc30
137 changed files with 41049 additions and 35990 deletions

47
.devcontainer/Dockerfile Normal file
View 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

View 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}"
}
}

View 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

View File

@ -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
View 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

View File

@ -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).

View 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

View File

@ -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

View File

@ -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 }}

View File

@ -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:

View File

@ -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 }}"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
View File

@ -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
View 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
View 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",
},
]
}

View File

@ -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 |

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 = {
'&gt;': '>',
'&lt;': '<',
'&amp;': '&',
}
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.

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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}'")

View File

@ -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;
}

View File

Before

Width:  |  Height:  |  Size: 461 KiB

After

Width:  |  Height:  |  Size: 461 KiB

View File

@ -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)

View File

@ -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."""

View File

@ -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."""

View File

@ -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

View File

@ -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() {

View File

@ -455,7 +455,9 @@ function loadUntrackedStockTable() {
);
}
loadUntrackedStockTable();
onPanelLoad('allocate', function() {
loadUntrackedStockTable();
});
{% endif %}

View File

@ -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."""

View File

@ -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"""

View File

@ -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."""

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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):

View File

@ -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 %}');
});

View File

@ -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 %}

View File

@ -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()

View File

@ -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

View 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'),
),
]

View File

@ -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'),
),
]

View File

@ -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.

View File

@ -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',
]

View File

@ -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() {

View File

@ -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

View File

@ -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>

View File

@ -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."""

View File

@ -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, '&lt;img src=# onerror=alert("pwned")&gt;')
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>&lt;script&gt;alert("h4x0r")&lt;/script&gt;')
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):

View File

@ -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'),
]

View File

@ -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, ]

View File

@ -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

View File

@ -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

View File

@ -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

View File

View File

@ -0,0 +1,10 @@
"""Very simple sample plugin"""
from plugin import InvenTreePlugin
class SimplePlugin(InvenTreePlugin):
"""A very simple plugin."""
NAME = 'SimplePlugin'
SLUG = "simple"

View File

@ -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 = [

View File

@ -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'],

View File

@ -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):

View 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'

View File

@ -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