2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-23 09:35:30 +00:00

Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters
2022-09-05 20:07:58 +10:00
137 changed files with 41049 additions and 35990 deletions
+47
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
+87
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}"
}
}
+14
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
-47
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
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
-26
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).
@@ -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
+1 -2
View File
@@ -21,10 +21,9 @@ jobs:
INVENTREE_MEDIA_ROOT: ./media INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static INVENTREE_STATIC_ROOT: ./static
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v2 uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
- name: Install Dependencies - name: Install Dependencies
run: | run: |
sudo apt-get update sudo apt-get update
+10 -16
View File
@@ -15,7 +15,7 @@ name: Docker
on: on:
release: release:
types: [published] types: [ published ]
push: push:
branches: branches:
@@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@v2 uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
- name: Version Check - name: Version Check
run: | run: |
pip install requests pip install requests
@@ -66,30 +66,30 @@ jobs:
test -f data/secret_key.txt test -f data/secret_key.txt
- name: Set up QEMU - name: Set up QEMU
if: github.event_name != 'pull_request' 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 - name: Set up Docker Buildx
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@f211e3e9ded2d9377c8cadc4489a4e38014bc4c9 # pin@v1
- name: Set up cosign - name: Set up cosign
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@48866aa521d8bf870604709cd43ec2f602d03ff2 uses: sigstore/cosign-installer@09a077b27eb1310dcfb21981bee195b30ce09de0 # pin@v2.5.0
- name: Login to Dockerhub - name: Login to Dockerhub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v1 uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7 # pin@v1
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract Docker metadata - name: Extract Docker metadata
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
id: meta id: meta
uses: docker/metadata-action@69f6fc9d46f2f8bf0d5491e4aabe0bb8c6a4678a uses: docker/metadata-action@69f6fc9d46f2f8bf0d5491e4aabe0bb8c6a4678a # pin@v4.0.1
with: with:
images: | images: |
inventree/inventree inventree/inventree
- name: Build and Push - name: Build and Push
id: build-and-push id: build-and-push
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/build-push-action@v2 uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a # pin@v2
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
@@ -103,11 +103,5 @@ jobs:
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
env: env:
COSIGN_EXPERIMENTAL: "true" COSIGN_EXPERIMENTAL: "true"
run: cosign sign ${{ steps.meta.outputs.tags }}@${{ steps.build-and-push.outputs.digest }} run: cosign sign ${{ steps.meta.outputs.tags }}@${{
- name: Push to Stable Branch steps.build-and-push.outputs.digest }}
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
+28 -29
View File
@@ -15,7 +15,6 @@ env:
python_version: 3.9 python_version: 3.9
node_version: 16 node_version: 16
# The OS version must be set per job # The OS version must be set per job
server_start_sleep: 60 server_start_sleep: 60
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -30,7 +29,7 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- name: Enviroment Setup - name: Enviroment Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -45,7 +44,7 @@ jobs:
needs: pep_style needs: pep_style
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- name: Enviroment Setup - name: Enviroment Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -67,7 +66,7 @@ jobs:
needs: pep_style needs: pep_style
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- name: Enviroment Setup - name: Enviroment Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -83,18 +82,18 @@ jobs:
needs: pep_style needs: pep_style
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
- name: Set up Python ${{ env.python_version }} - name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@v2 uses: actions/setup-python@7f80679172b057fc5e90d70d197929d454754a5a # pin@v2
with: with:
python-version: ${{ env.python_version }} python-version: ${{ env.python_version }}
cache: 'pip' cache: 'pip'
- name: Run pre-commit Checks - name: Run pre-commit Checks
uses: pre-commit/action@v2.0.3 uses: pre-commit/action@9b88afc9cd57fd75b655d5c71bd38146d07135fe # pin@v2.0.3
- name: Check Version - name: Check Version
run: | run: |
pip install requests pip install requests
python3 ci/version_check.py python3 ci/version_check.py
python: python:
name: Tests - inventree-python name: Tests - inventree-python
@@ -114,7 +113,7 @@ jobs:
INVENTREE_PYTHON_TEST_PASSWORD: testpassword INVENTREE_PYTHON_TEST_PASSWORD: testpassword
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- name: Enviroment Setup - name: Enviroment Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -122,7 +121,8 @@ jobs:
dev-install: true dev-install: true
update: true update: true
- name: Download Python Code For `${{ env.wrapper_name }}` - 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 - name: Start InvenTree Server
run: | run: |
invoke delete-data -f invoke delete-data -f
@@ -143,7 +143,7 @@ jobs:
continue-on-error: true continue-on-error: true
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- name: Enviroment Setup - name: Enviroment Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -155,8 +155,8 @@ jobs:
name: Tests - DB [SQLite] + Coverage name: Tests - DB [SQLite] + Coverage
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: ['javascript', 'html', 'pre-commit'] needs: [ 'javascript', 'html', 'pre-commit' ]
continue-on-error: true # continue if a step fails so that coverage gets pushed continue-on-error: true # continue if a step fails so that coverage gets pushed
env: env:
INVENTREE_DB_NAME: ./inventree.sqlite INVENTREE_DB_NAME: ./inventree.sqlite
@@ -164,7 +164,7 @@ jobs:
INVENTREE_PLUGINS_ENABLED: true INVENTREE_PLUGINS_ENABLED: true
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- name: Enviroment Setup - name: Enviroment Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -185,9 +185,7 @@ jobs:
postgres: postgres:
name: Tests - DB [PostgreSQL] name: Tests - DB [PostgreSQL]
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [ 'javascript', 'html', 'pre-commit' ]
needs: ['javascript', 'html', 'pre-commit']
if: github.event_name == 'push'
env: env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql INVENTREE_DB_ENGINE: django.db.backends.postgresql
@@ -214,7 +212,7 @@ jobs:
- 6379:6379 - 6379:6379
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- name: Enviroment Setup - name: Enviroment Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -231,7 +229,7 @@ jobs:
name: Tests - DB [MySQL] name: Tests - DB [MySQL]
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: ['javascript', 'html', 'pre-commit'] needs: [ 'javascript', 'html', 'pre-commit' ]
if: github.event_name == 'push' if: github.event_name == 'push'
env: env:
@@ -253,12 +251,13 @@ jobs:
MYSQL_USER: inventree MYSQL_USER: inventree
MYSQL_PASSWORD: password MYSQL_PASSWORD: password
MYSQL_ROOT_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: ports:
- 3306:3306 - 3306:3306
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # pin@v1
- name: Enviroment Setup - name: Enviroment Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
+34 -14
View File
@@ -3,15 +3,35 @@
name: Publish release notes name: Publish release notes
on: on:
release: release:
types: [published] types: [ published ]
jobs: 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: tweet:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: Eomm/why-don-t-you-tweet@v1 - uses: Eomm/why-don-t-you-tweet@f61f2a86c30c46528c1398a1abb1f64aa0988f69 # pin@v1
with: 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: env:
TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }} TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }}
TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }} TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }}
@@ -19,14 +39,14 @@ jobs:
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
reddit: reddit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: bluwy/release-for-reddit-action@v1 - uses: bluwy/release-for-reddit-action@4d948192aff856da22f19f9806b00b46ca384547 # pin@v1
with: with:
username: ${{ secrets.REDDIT_USERNAME }} username: ${{ secrets.REDDIT_USERNAME }}
password: ${{ secrets.REDDIT_PASSWORD }} password: ${{ secrets.REDDIT_PASSWORD }}
app-id: ${{ secrets.REDDIT_APP_ID }} app-id: ${{ secrets.REDDIT_APP_ID }}
app-secret: ${{ secrets.REDDIT_APP_SECRET }} app-secret: ${{ secrets.REDDIT_APP_SECRET }}
subreddit: InvenTree subreddit: InvenTree
title: "InvenTree version ${{ github.event.release.tag_name }} released" title: "InvenTree version ${{ github.event.release.tag_name }} released"
comment: "${{ github.event.release.body }}" comment: "${{ github.event.release.body }}"
+11 -10
View File
@@ -3,7 +3,7 @@ name: Mark stale issues and pull requests
on: on:
schedule: schedule:
- cron: '24 11 * * *' - cron: '24 11 * * *'
jobs: jobs:
stale: stale:
@@ -14,12 +14,13 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@v3 - uses: actions/stale@98ed4cb500039dbcccf4bd9bedada4d0187f2757 # pin@v3
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue seems stale. Please react to show this is still important.' stale-issue-message: 'This issue seems stale. Please react to show this is still
stale-pr-message: 'This PR seems stale. Please react to show this is still important.' important.'
stale-issue-label: 'inactive' stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
stale-pr-label: 'inactive' stale-issue-label: 'inactive'
start-date: '2022-01-01' stale-pr-label: 'inactive'
exempt-all-milestones: true start-date: '2022-01-01'
exempt-all-milestones: true
+7 -7
View File
@@ -20,17 +20,17 @@ jobs:
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v2 uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
- name: Set up Python 3.9 - name: Set up Python 3.9
uses: actions/setup-python@v1 uses: actions/setup-python@152ba7c4dd6521b8e9c93f72d362ce03bf6c4f20 # pin@v1
with: with:
python-version: 3.9 python-version: 3.9
- name: Install Dependencies - name: Install Dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y gettext sudo apt-get install -y gettext
pip3 install invoke pip3 install invoke
invoke install invoke install
- name: Make Translations - name: Make Translations
run: | run: |
invoke translate invoke translate
@@ -42,7 +42,7 @@ jobs:
git add "*.po" git add "*.po"
git commit -m "updated translation base" git commit -m "updated translation base"
- name: Push changes - name: Push changes
uses: ad-m/github-push-action@master uses: ad-m/github-push-action@9a46ba8d86d3171233e861a4351b1278a2805c83 # pin@master
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
branch: l10 branch: l10
+5 -4
View File
@@ -1,7 +1,7 @@
name: Update dependency files regularly name: Update dependency files regularly
on: on:
workflow_dispatch: workflow_dispatch: null
schedule: schedule:
- cron: "0 0 * * *" - cron: "0 0 * * *"
@@ -9,14 +9,15 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e # pin@v2
- name: Setup - name: Setup
run: pip install -r requirements-dev.txt run: pip install -r requirements-dev.txt
- name: Update requirements.txt - name: Update requirements.txt
run: pip-compile --output-file=requirements.txt requirements.in -U run: pip-compile --output-file=requirements.txt requirements.in -U
- name: Update requirements-dev.txt - name: Update requirements-dev.txt
run: pip-compile --generate-hashes --output-file=requirements-dev.txt requirements-dev.in -U run: pip-compile --generate-hashes --output-file=requirements-dev.txt
- uses: stefanzweifel/git-auto-commit-action@v4 requirements-dev.in -U
- uses: stefanzweifel/git-auto-commit-action@49620cd3ed21ee620a48530e81dba0d139c9cb80 # pin@v4
with: with:
commit_message: "[Bot] Updated dependency" commit_message: "[Bot] Updated dependency"
branch: dep-update branch: dep-update
+12 -12
View File
@@ -2,9 +2,9 @@
name: Welcome name: Welcome
on: on:
pull_request: pull_request:
types: [opened] types: [ opened ]
issues: issues:
types: [opened] types: [ opened ]
jobs: jobs:
run: run:
@@ -13,13 +13,13 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/first-interaction@v1 - uses: actions/first-interaction@bd33205aa5c96838e10fd65df0d01efd613677c1 # pin@v1
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: | issue-message: |
Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help. 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/). If you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).
pr-message: | pr-message: |
This is your first PR, welcome! 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. 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. Make sure to document why this PR is needed and to link connected issues so we can review it faster.
+8 -1
View File
@@ -66,9 +66,16 @@ secret_key.txt
# IDE / development files # IDE / development files
.idea/ .idea/
*.code-workspace *.code-workspace
.vscode/
.bash_history .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 reports
.coverage .coverage
htmlcov/ htmlcov/
+26
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
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",
},
]
}
+4 -1
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: For strings exposed via Python code, use the following format:
```python ```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!') 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: The tags describe issues and PRs in multiple areas:
| Area | Name | Description | | 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 | | | | Type Labels | | |
| | breaking | Indicates a major update or change which breaks compatibility | | | breaking | Indicates a major update or change which breaks compatibility |
| | bug | Identifies a bug which needs to be addressed | | | bug | Identifies a bug which needs to be addressed |
+2
View File
@@ -12,6 +12,7 @@ from rest_framework.serializers import ValidationError
from InvenTree.mixins import ListCreateAPI from InvenTree.mixins import ListCreateAPI
from InvenTree.permissions import RolePermission from InvenTree.permissions import RolePermission
from part.templatetags.inventree_extras import plugins_info
from .status import is_worker_running from .status import is_worker_running
from .version import (inventreeApiVersion, inventreeInstanceName, from .version import (inventreeApiVersion, inventreeInstanceName,
@@ -36,6 +37,7 @@ class InfoView(AjaxView):
'apiVersion': inventreeApiVersion(), 'apiVersion': inventreeApiVersion(),
'worker_running': is_worker_running(), 'worker_running': is_worker_running(),
'plugins_enabled': settings.PLUGINS_ENABLED, 'plugins_enabled': settings.PLUGINS_ENABLED,
'active_plugins': plugins_info(),
} }
return JsonResponse(data) return JsonResponse(data)
+14 -1
View File
@@ -2,11 +2,24 @@
# InvenTree API version # 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 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 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 PartCategory list API
- Adds a 'depth' parameter to the StockLocation list API - Adds a 'depth' parameter to the StockLocation list API
+27
View File
@@ -203,3 +203,30 @@ def get_secret_key():
key_data = secret_key_file.read_text().strip() key_data = secret_key_file.read_text().strip()
return key_data 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
+65
View File
@@ -20,7 +20,9 @@ from django.http import StreamingHttpResponse
from django.test import TestCase from django.test import TestCase
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import regex
import requests import requests
from bleach import clean
from djmoney.money import Money from djmoney.money import Money
from PIL import Image from PIL import Image
@@ -265,6 +267,20 @@ def getLogoImage(as_file=False, custom=True):
return getStaticUrl('img/inventree.png') 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): def TestIfImageURL(url):
"""Test if an image URL (or filename) looks like a valid image format. """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() 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'): def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = 'object_id'):
"""Lookup method for the GenericForeignKey fields. """Lookup method for the GenericForeignKey fields.
+6 -1
View File
@@ -7,7 +7,7 @@ from django.conf import settings
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect 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, from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
BaseRequire2FAMiddleware) BaseRequire2FAMiddleware)
@@ -41,6 +41,11 @@ class AuthRequiredMiddleware(object):
if request.path_info.startswith('/api/'): if request.path_info.startswith('/api/'):
return self.get_response(request) 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: if not request.user.is_authenticated:
""" """
Normally, a web-based session would use csrftoken based authentication. Normally, a web-based session would use csrftoken based authentication.
+31 -7
View File
@@ -1,15 +1,16 @@
"""Mixins for (API) views in the whole project.""" """Mixins for (API) views in the whole project."""
from bleach import clean
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.response import Response from rest_framework.response import Response
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
class CleanMixin(): 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 # Define a list of field names which will *not* be cleaned
SAFE_FIELDS = {} SAFE_FIELDS = []
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
"""Override to clean data before processing it.""" """Override to clean data before processing it."""
@@ -34,6 +35,22 @@ class CleanMixin():
return Response(serializer.data) 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: def clean_data(self, data: dict) -> dict:
"""Clean / sanitize data. """Clean / sanitize data.
@@ -46,17 +63,24 @@ class CleanMixin():
data (dict): Data that should be sanatized. data (dict): Data that should be sanatized.
Returns: Returns:
dict: Profided data sanatized; still in the same order. dict: Provided data sanatized; still in the same order.
""" """
clean_data = {} clean_data = {}
for k, v in data.items(): 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): elif isinstance(v, dict):
ret = self.clean_data(v) ret = self.clean_data(v)
else: else:
ret = v ret = v
clean_data[k] = ret clean_data[k] = ret
return clean_data return clean_data
+10
View File
@@ -1,5 +1,7 @@
"""Permission set for InvenTree.""" """Permission set for InvenTree."""
from functools import wraps
from rest_framework import permissions from rest_framework import permissions
import users.models import users.models
@@ -63,3 +65,11 @@ class RolePermission(permissions.BasePermission):
result = users.models.RuleSet.check_table_permission(user, table, permission) result = users.models.RuleSet.check_table_permission(user, table, permission)
return result 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)
+7 -25
View File
@@ -16,8 +16,6 @@ import sys
from pathlib import Path from pathlib import Path
import django.conf.locale 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.http import Http404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -26,7 +24,7 @@ import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
from . import config 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" # Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv 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_TLS = get_boolean_setting('INVENTREE_EMAIL_TLS', 'email.tls', False)
EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', 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_USE_LOCALTIME = False
EMAIL_TIMEOUT = 60 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? PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# User interface customization values # 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', {}) 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: if DEBUG:
logger.info("InvenTree running with DEBUG enabled") logger.info("InvenTree running with DEBUG enabled")
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'") logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'") logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
+6 -1
View File
@@ -19,8 +19,8 @@ main {
} }
.login-screen { .login-screen {
background: url(/static/img/paper_splash.jpg) no-repeat center fixed;
background-size: cover; background-size: cover;
background-position: center;
height: 100vh; height: 100vh;
font-family: 'Numans', sans-serif; font-family: 'Numans', sans-serif;
color: #eee; color: #eee;
@@ -1028,3 +1028,8 @@ a {
margin-top: 3px; margin-top: 3px;
overflow: hidden; overflow: hidden;
} }
.treeview .node-icon {
margin-left: 0.25rem;
margin-right: 0.25rem;
}

Before

Width:  |  Height:  |  Size: 461 KiB

After

Width:  |  Height:  |  Size: 461 KiB

+4
View File
@@ -38,6 +38,7 @@ from part.models import PartCategory
from users.models import RuleSet, check_user_role from users.models import RuleSet, check_user_role
from .forms import EditUserForm, SetPasswordForm from .forms import EditUserForm, SetPasswordForm
from .helpers import remove_non_printable_characters, strip_html_tags
def auth_request(request): def auth_request(request):
@@ -600,6 +601,9 @@ class SearchView(TemplateView):
query = request.POST.get('search', '') query = request.POST.get('search', '')
query = strip_html_tags(query, raise_error=False)
query = remove_non_printable_characters(query)
context['query'] = query context['query'] = query
return super(TemplateView, self).render_to_response(context) return super(TemplateView, self).render_to_response(context)
+14 -5
View File
@@ -100,6 +100,8 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
'reference': ['reference_int', 'reference'], 'reference': ['reference_int', 'reference'],
} }
ordering = '-reference'
search_fields = [ search_fields = [
'reference', 'reference',
'title', 'title',
@@ -365,6 +367,7 @@ class BuildItemList(ListCreateAPI):
kwargs['part_detail'] = str2bool(params.get('part_detail', False)) kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['build_detail'] = str2bool(params.get('build_detail', False)) kwargs['build_detail'] = str2bool(params.get('build_detail', False))
kwargs['location_detail'] = str2bool(params.get('location_detail', False)) kwargs['location_detail'] = str2bool(params.get('location_detail', False))
kwargs['stock_detail'] = str2bool(params.get('stock_detail', True))
except AttributeError: except AttributeError:
pass pass
@@ -372,13 +375,19 @@ class BuildItemList(ListCreateAPI):
def get_queryset(self): def get_queryset(self):
"""Override the queryset method, to allow filtering by stock_item.part.""" """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') queryset = queryset.select_related(
query = query.select_related('stock_item__part') 'bom_item',
query = query.select_related('stock_item__part__category') 'bom_item__sub_part',
'build',
'install_into',
'stock_item',
'stock_item__location',
'stock_item__part',
)
return query return queryset
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""Customm query filtering for the BuildItem list.""" """Customm query filtering for the BuildItem list."""
+46 -20
View File
@@ -674,28 +674,26 @@ class Build(MPTTModel, ReferenceIndexingMixin):
parts = bom_item.get_valid_parts_for_allocation() 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, Test if there is a matching serial number!
serial=str(serial), """
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, quantity=1,
).filter(StockModels.StockItem.IN_STOCK_FILTER) install_into=output,
)
"""
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,
)
else: else:
"""Create a single build output of the given quantity.""" """Create a single build output of the given quantity."""
@@ -736,6 +734,34 @@ class Build(MPTTModel, ReferenceIndexingMixin):
# Remove the build output from the database # Remove the build output from the database
output.delete() 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 @transaction.atomic
def subtract_allocated_stock(self, user): def subtract_allocated_stock(self, user):
"""Called when the Build is marked as "complete", this function removes the allocated untracked items from stock.""" """Called when the Build is marked as "complete", this function removes the allocated untracked items from stock."""
+28 -8
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): class BuildCompleteSerializer(serializers.Serializer):
"""DRF serializer for marking a BuildOrder as complete.""" """DRF serializer for marking a BuildOrder as complete."""
accept_overallocated = serializers.BooleanField( accept_overallocated = serializers.ChoiceField(
label=_('Accept Overallocated'), label=_('Overallocated Stock'),
help_text=_('Accept stock items which have been overallocated to this build order'), choices=list(OverallocationChoice.OPTIONS.items()),
help_text=_('How do you want to handle extra stock items assigned to the build order'),
required=False, required=False,
default=False, default=OverallocationChoice.REJECT,
) )
def validate_accept_overallocated(self, value): def validate_accept_overallocated(self, value):
"""Check if the 'accept_overallocated' field is required""" """Check if the 'accept_overallocated' field is required"""
build = self.context['build'] 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')) raise ValidationError(_('Some stock items have been overallocated'))
return value return value
@@ -531,9 +546,6 @@ class BuildCompleteSerializer(serializers.Serializer):
if build.incomplete_count > 0: if build.incomplete_count > 0:
raise ValidationError(_("Build order has incomplete outputs")) 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 return data
def save(self): def save(self):
@@ -541,6 +553,10 @@ class BuildCompleteSerializer(serializers.Serializer):
request = self.context['request'] request = self.context['request']
build = self.context['build'] 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) build.complete_build(request.user)
@@ -840,6 +856,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
build_detail = kwargs.pop('build_detail', False) build_detail = kwargs.pop('build_detail', False)
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
location_detail = kwargs.pop('location_detail', False) location_detail = kwargs.pop('location_detail', False)
stock_detail = kwargs.pop('stock_detail', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -852,6 +869,9 @@ class BuildItemSerializer(InvenTreeModelSerializer):
if not location_detail: if not location_detail:
self.fields.pop('location_detail') self.fields.pop('location_detail')
if not stock_detail:
self.fields.pop('stock_item_detail')
class Meta: class Meta:
"""Serializer metaclass""" """Serializer metaclass"""
model = BuildItem model = BuildItem
+11 -12
View File
@@ -55,6 +55,9 @@ src="{% static 'img/blank_image.png' %}"
{% if build.is_active %} {% 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> <li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %} {% 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 %} {% 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> <li><a class='dropdown-item' href='#' id='build-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Build" %}</a>
{% endif %} {% endif %}
@@ -209,6 +212,7 @@ src="{% static 'img/blank_image.png' %}"
{% block js_ready %} {% block js_ready %}
{% if roles.build.change %}
$("#build-edit").click(function () { $("#build-edit").click(function () {
editBuildOrder({{ build.pk }}); editBuildOrder({{ build.pk }});
}); });
@@ -224,24 +228,19 @@ src="{% static 'img/blank_image.png' %}"
}); });
$("#build-complete").on('click', function() { $("#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 }}, { completeBuildOrder({{ build.pk }}, {
overallocated: {% if build.has_overallocated_parts %}true{% else %}false{% endif %}, overallocated: {% if build.has_overallocated_parts %}true{% else %}false{% endif %},
allocated: {% if build.are_untracked_parts_allocated %}true{% else %}false{% endif %}, allocated: {% if build.are_untracked_parts_allocated %}true{% else %}false{% endif %},
completed: {% if build.remaining == 0 %}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 %} {% if report_enabled %}
$('#print-build-report').click(function() { $('#print-build-report').click(function() {
+3 -1
View File
@@ -455,7 +455,9 @@ function loadUntrackedStockTable() {
); );
} }
loadUntrackedStockTable(); onPanelLoad('allocate', function() {
loadUntrackedStockTable();
});
{% endif %} {% endif %}
+99
View File
@@ -717,6 +717,105 @@ class BuildAllocationTest(BuildAPITest):
self.assertEqual(allocation.stock_item.pk, 2) 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): class BuildListTest(BuildAPITest):
"""Tests for the BuildOrder LIST API.""" """Tests for the BuildOrder LIST API."""
+66 -3
View File
@@ -7,6 +7,7 @@ from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Sum
from InvenTree import status_codes as status from InvenTree import status_codes as status
@@ -17,6 +18,9 @@ from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem from stock.models import StockItem
from users.models import Owner from users.models import Owner
import logging
logger = logging.getLogger('inventree')
class BuildTestBase(TestCase): class BuildTestBase(TestCase):
"""Run some tests to ensure that the Build model is working properly.""" """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_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_3 = 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_4 = 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_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) 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()) 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): def test_cancel(self):
"""Test cancellation of the build""" """Test cancellation of the build"""
+24 -1
View File
@@ -131,7 +131,7 @@ class BaseInvenTreeSetting(models.Model):
for k, v in kwargs.items(): for k, v in kwargs.items():
key += f"_{k}:{v}" key += f"_{k}:{v}"
return key return key.replace(" ", "")
@classmethod @classmethod
def allValues(cls, user=None, exclude_hidden=False): def allValues(cls, user=None, exclude_hidden=False):
@@ -1070,6 +1070,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': InvenTree.validators.validate_part_name_format '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': { 'LABEL_ENABLE': {
'name': _('Enable label printing'), 'name': _('Enable label printing'),
'description': _('Enable label printing from the web interface'), 'description': _('Enable label printing from the web interface'),
@@ -1168,6 +1174,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'STOCK_LOCATION_DEFAULT_ICON': {
'name': _('Stock Location Default Icon'),
'description': _('Stock location default icon (empty means no icon)'),
'default': '',
},
'BUILDORDER_REFERENCE_PATTERN': { 'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Pattern'), 'name': _('Build Order Reference Pattern'),
'description': _('Required pattern for generating Build Order reference field'), 'description': _('Required pattern for generating Build Order reference field'),
@@ -1268,6 +1280,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'requires_restart': True, '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 # Settings for plugin mixin features
'ENABLE_PLUGINS_URL': { 'ENABLE_PLUGINS_URL': {
'name': _('Enable URL integration'), 'name': _('Enable URL integration'),
@@ -1310,6 +1329,8 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
} }
typ = 'inventree'
class Meta: class Meta:
"""Meta options for InvenTreeSetting.""" """Meta options for InvenTreeSetting."""
@@ -1623,6 +1644,8 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
}, },
} }
typ = 'user'
class Meta: class Meta:
"""Meta options for InvenTreeUserSetting.""" """Meta options for InvenTreeUserSetting."""
+3
View File
@@ -70,6 +70,7 @@ class GlobalSettingsSerializer(SettingsSerializer):
'choices', 'choices',
'model_name', 'model_name',
'api_url', 'api_url',
'typ',
] ]
@@ -93,6 +94,7 @@ class UserSettingsSerializer(SettingsSerializer):
'choices', 'choices',
'model_name', 'model_name',
'api_url', 'api_url',
'typ',
] ]
@@ -122,6 +124,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'choices', 'choices',
'model_name', 'model_name',
'api_url', 'api_url',
'typ',
] ]
# set Meta class # set Meta class
+1
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> # 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> # 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 # logo: logo.png
# splash: splash_screen.jpg
# hide_admin_link: true # hide_admin_link: true
# hide_password_reset: true # hide_password_reset: true
+5 -9
View File
@@ -158,16 +158,12 @@ class LabelPrintMixin:
pages = [] pages = []
if len(outputs) > 1: for output in outputs:
# If more than one output is generated, merge them into a single file doc = output.get_document()
for output in outputs: for page in doc.pages:
doc = output.get_document() pages.append(page)
for page in doc.pages:
pages.append(page)
pdf = outputs[0].get_document().copy(pages).write_pdf() pdf = outputs[0].get_document().copy(pages).write_pdf()
else:
pdf = outputs[0].get_document().write_pdf()
inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user) inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user)
@@ -1,10 +1,13 @@
{% load l10n %}
{% load report %} {% load report %}
{% load barcode %} {% load barcode %}
<head> <head>
<style> <style>
@page { @page {
{% localize off %}
size: {{ width }}mm {{ height }}mm; size: {{ width }}mm {{ height }}mm;
{% endlocalize %}
{% block margin %} {% block margin %}
margin: 0mm; margin: 0mm;
{% endblock %} {% endblock %}
@@ -15,6 +18,8 @@
margin: 0mm; margin: 0mm;
color: #000; color: #000;
background-color: #FFF; background-color: #FFF;
page-break-before: always;
page-break-after: always;
} }
img { img {
@@ -22,14 +27,23 @@
image-rendering: pixelated; image-rendering: pixelated;
} }
.content {
width: 100%;
break-after: always;
position: relative;
}
{% block style %} {% block style %}
/* User-defined styles can go here */
{% endblock %} {% endblock %}
</style> </style>
</head> </head>
<body> <body>
{% block content %} <div class='content'>
<!-- Label data rendered here! --> {% block content %}
{% endblock %} <!-- Label data rendered here! -->
{% endblock %}
</div>
</body> </body>
@@ -1,5 +1,6 @@
{% extends "label/label_base.html" %} {% extends "label/label_base.html" %}
{% load l10n %}
{% load barcode %} {% load barcode %}
{% block style %} {% block style %}
@@ -8,15 +9,19 @@
position: fixed; position: fixed;
left: 0mm; left: 0mm;
top: 0mm; top: 0mm;
{% localize off %}
height: {{ height }}mm; height: {{ height }}mm;
width: {{ height }}mm; width: {{ height }}mm;
{% endlocalize %}
} }
.part { .part {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
display: inline; display: inline;
position: absolute; position: absolute;
{% localize off %}
left: {{ height }}mm; left: {{ height }}mm;
{% endlocalize %}
top: 2mm; top: 2mm;
} }
@@ -1,5 +1,6 @@
{% extends "label/label_base.html" %} {% extends "label/label_base.html" %}
{% load l10n %}
{% load barcode %} {% load barcode %}
{% block style %} {% block style %}
@@ -8,15 +9,19 @@
position: fixed; position: fixed;
left: 0mm; left: 0mm;
top: 0mm; top: 0mm;
{% localize off %}
height: {{ height }}mm; height: {{ height }}mm;
width: {{ height }}mm; width: {{ height }}mm;
{% endlocalize %}
} }
.part { .part {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
display: inline; display: inline;
position: absolute; position: absolute;
{% localize off %}
left: {{ height }}mm; left: {{ height }}mm;
{% endlocalize %}
top: 2mm; top: 2mm;
} }
@@ -1,5 +1,6 @@
{% extends "label/label_base.html" %} {% extends "label/label_base.html" %}
{% load l10n %}
{% load barcode %} {% load barcode %}
{% block style %} {% block style %}
@@ -8,8 +9,10 @@
position: fixed; position: fixed;
left: 0mm; left: 0mm;
top: 0mm; top: 0mm;
{% localize off %}
height: {{ height }}mm; height: {{ height }}mm;
width: {{ height }}mm; width: {{ height }}mm;
{% endlocalize %}
} }
{% endblock %} {% endblock %}
@@ -1,5 +1,6 @@
{% extends "label/label_base.html" %} {% extends "label/label_base.html" %}
{% load l10n %}
{% load barcode %} {% load barcode %}
{% block style %} {% block style %}
@@ -8,8 +9,10 @@
position: fixed; position: fixed;
left: 0mm; left: 0mm;
top: 0mm; top: 0mm;
{% localize off %}
height: {{ height }}mm; height: {{ height }}mm;
width: {{ height }}mm; width: {{ height }}mm;
{% endlocalize %}
} }
{% endblock %} {% endblock %}
@@ -1,5 +1,6 @@
{% extends "label/label_base.html" %} {% extends "label/label_base.html" %}
{% load l10n %}
{% load barcode %} {% load barcode %}
{% block style %} {% block style %}
@@ -8,15 +9,19 @@
position: fixed; position: fixed;
left: 0mm; left: 0mm;
top: 0mm; top: 0mm;
{% localize off %}
height: {{ height }}mm; height: {{ height }}mm;
width: {{ height }}mm; width: {{ height }}mm;
{% endlocalize %}
} }
.loc { .loc {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
display: inline; display: inline;
position: absolute; position: absolute;
{% localize off %}
left: {{ height }}mm; left: {{ height }}mm;
{% endlocalize %}
top: 2mm; 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
+45 -6
View File
@@ -1,10 +1,13 @@
"""JSON API for the Order app.""" """JSON API for the Order app."""
from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.urls import include, path, re_path 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 django_filters import rest_framework as rest_filters
from rest_framework import filters, status from rest_framework import filters, status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
import order.models as models import order.models as models
@@ -116,12 +119,48 @@ class PurchaseOrderList(APIDownloadMixin, ListCreateAPI):
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
"""Save user information on create.""" """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) serializer.is_valid(raise_exception=True)
item = serializer.save() with transaction.atomic():
item.created_by = request.user order = serializer.save()
item.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) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@@ -253,7 +292,7 @@ class PurchaseOrderList(APIDownloadMixin, ListCreateAPI):
'status', 'status',
] ]
ordering = '-creation_date' ordering = '-reference'
class PurchaseOrderDetail(RetrieveUpdateDestroyAPI): class PurchaseOrderDetail(RetrieveUpdateDestroyAPI):
@@ -694,7 +733,7 @@ class SalesOrderList(APIDownloadMixin, ListCreateAPI):
'customer_reference', 'customer_reference',
] ]
ordering = '-creation_date' ordering = '-reference'
class SalesOrderDetail(RetrieveUpdateDestroyAPI): class SalesOrderDetail(RetrieveUpdateDestroyAPI):
+4 -4
View File
@@ -702,7 +702,7 @@ class SalesOrder(Order):
"""Check if this order is "shipped" (all line items delivered).""" """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()]) 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. """Test if this SalesOrder can be completed.
Throws a ValidationError if cannot be completed. Throws a ValidationError if cannot be completed.
@@ -720,7 +720,7 @@ class SalesOrder(Order):
elif self.pending_shipment_count > 0: elif self.pending_shipment_count > 0:
raise ValidationError(_("Order cannot be completed as there are incomplete shipments")) 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")) raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
except ValidationError as e: except ValidationError as e:
@@ -732,9 +732,9 @@ class SalesOrder(Order):
return True return True
def complete_order(self, user): def complete_order(self, user, **kwargs):
"""Mark this order as "complete.""" """Mark this order as "complete."""
if not self.can_complete(): if not self.can_complete(**kwargs):
return False return False
self.status = SalesOrderStatus.SHIPPED self.status = SalesOrderStatus.SHIPPED
+54 -3
View File
@@ -19,7 +19,7 @@ import stock.models
import stock.serializers import stock.serializers
from common.settings import currency_code_mappings from common.settings import currency_code_mappings
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer 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, from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
@@ -204,6 +204,23 @@ class PurchaseOrderCancelSerializer(serializers.Serializer):
class PurchaseOrderCompleteSerializer(serializers.Serializer): class PurchaseOrderCompleteSerializer(serializers.Serializer):
"""Serializer for completing a purchase order.""" """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: class Meta:
"""Metaclass options.""" """Metaclass options."""
@@ -1079,13 +1096,43 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
class SalesOrderCompleteSerializer(serializers.Serializer): class SalesOrderCompleteSerializer(serializers.Serializer):
"""DRF serializer for manually marking a sales order as complete.""" """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): def validate(self, data):
"""Custom validation for the serializer""" """Custom validation for the serializer"""
data = super().validate(data) data = super().validate(data)
order = self.context['order'] 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 return data
@@ -1093,10 +1140,14 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
"""Save the serializer to complete the SalesOrder""" """Save the serializer to complete the SalesOrder"""
request = self.context['request'] request = self.context['request']
order = self.context['order'] order = self.context['order']
data = self.validated_data
user = getattr(request, 'user', None) 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): class SalesOrderCancelSerializer(serializers.Serializer):
+19 -22
View File
@@ -46,6 +46,9 @@
{% if order.can_cancel %} {% 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> <li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
{% endif %} {% 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> </ul>
</div> </div>
{% if order.status == PurchaseOrderStatus.PENDING %} {% if order.status == PurchaseOrderStatus.PENDING %}
@@ -217,30 +220,14 @@ $('#print-order-report').click(function() {
}); });
{% endif %} {% endif %}
{% if roles.purchase_order.change %}
$("#edit-order").click(function() { $("#edit-order").click(function() {
constructForm('{% url "api-po-detail" order.pk %}', { editPurchaseOrder({{ order.pk }}, {
fields: { {% if order.lines.count > 0 or order.status != PurchaseOrderStatus.PENDING %}
reference: { hide_supplier: true,
icon: 'fa-hashtag', {% endif %}
},
{% 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" %}',
reload: true, 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() { $("#export-order").click(function() {
exportOrder('{% url "po-export" order.id %}'); exportOrder('{% url "po-export" order.id %}');
}); });
@@ -64,7 +64,7 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
{% if order.status == SalesOrderStatus.PENDING %} {% 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" %} <span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
</button> </button>
{% endif %} {% endif %}
@@ -253,12 +253,12 @@ $("#cancel-order").click(function() {
}); });
$("#complete-order").click(function() { $("#complete-order").click(function() {
constructForm('{% url "api-so-complete" order.id %}', { completeSalesOrder(
method: 'POST', {{ order.pk }},
title: '{% trans "Complete Sales Order" %}', {
confirm: true, reload: true,
reload: true, }
}); );
}); });
{% if report_enabled %} {% if report_enabled %}
+71 -1
View File
@@ -225,6 +225,64 @@ class PurchaseOrderTest(OrderTest):
expected_code=201 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): def test_po_cancel(self):
"""Test the PurchaseOrderCancel API endpoint.""" """Test the PurchaseOrderCancel API endpoint."""
po = models.PurchaseOrder.objects.get(pk=1) po = models.PurchaseOrder.objects.get(pk=1)
@@ -264,7 +322,19 @@ class PurchaseOrderTest(OrderTest):
self.assignRole('purchase_order.add') 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() po.refresh_from_db()
+96 -46
View File
@@ -1,6 +1,6 @@
"""Provides a JSON API for the Part app.""" """Provides a JSON API for the Part app."""
import datetime import functools
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.db import transaction from django.db import transaction
@@ -474,27 +474,27 @@ class PartScheduling(RetrieveAPI):
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
"""Return scheduling information for the referenced Part instance""" """Return scheduling information for the referenced Part instance"""
today = datetime.datetime.now().date()
part = self.get_object() part = self.get_object()
schedule = [] 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: """Check if a scheduled entry should be added:
- date must be non-null - date must be non-null
- date cannot be in the "past" - date cannot be in the "past"
- quantity must not be zero - quantity must not be zero
""" """
if date and date >= today and quantity != 0:
schedule.append({ schedule.append({
'date': date, 'date': date,
'quantity': quantity, 'quantity': quantity,
'title': title, 'speculative_quantity': speculative_quantity,
'label': label, 'title': title,
'url': url, 'label': label,
}) 'url': url,
})
# Add purchase order (incoming stock) information # Add purchase order (incoming stock) information
po_lines = order.models.PurchaseOrderLineItem.objects.filter( 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. and just looking at what stock items the user has actually allocated against the Build.
""" """
build_allocations = BuildItem.objects.filter( # Grab a list of BomItem objects that this part might be used in
stock_item__part=part, bom_items = BomItem.objects.filter(part.get_used_in_bom_item_filter())
build__status__in=BuildStatus.ACTIVE_CODES,
)
for allocation in build_allocations: # Track all outstanding build orders
seen_builds = set()
add_schedule_entry( for bom_item in bom_items:
allocation.build.target_date, # Find a list of active builds for this BomItem
-allocation.quantity,
_('Stock required for Build Order'), if bom_item.inherited:
str(allocation.build), # An "inherited" BOM item filters down to variant parts also
allocation.build.get_absolute_url(), 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 # 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) return Response(schedule)
@@ -1746,28 +1817,7 @@ class BomList(ListCreateDestroyAPIView):
# Extract the part we are interested in # Extract the part we are interested in
uses_part = Part.objects.get(pk=uses) uses_part = Part.objects.get(pk=uses)
# Construct the database query in multiple parts queryset = queryset.filter(uses_part.get_used_in_bom_item_filter())
# 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)
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass pass
@@ -0,0 +1,18 @@
# Generated by Django 3.2.15 on 2022-08-15 08:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0083_auto_20220731_2357'),
]
operations = [
migrations.AddField(
model_name='partcategory',
name='icon',
field=models.CharField(blank=True, help_text='Icon (optional)', max_length=100, verbose_name='Icon'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.15 on 2022-08-24 12:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0084_partcategory_icon'),
]
operations = [
migrations.AddField(
model_name='partparametertemplate',
name='description',
field=models.CharField(blank=True, help_text='Parameter description', max_length=250, verbose_name='Description'),
),
]
+54 -1
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')) 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 @staticmethod
def get_api_url(): def get_api_url():
"""Return the API url associated with the PartCategory model""" """Return the API url associated with the PartCategory model"""
@@ -1429,6 +1436,45 @@ class Part(MetadataMixin, MPTTModel):
return parts 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): def get_used_in_filter(self, include_inherited=True):
"""Return a query filter for all parts that this part is used in. """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): def validate_template_name(name):
"""Prevent illegal characters in "name" field for PartParameterTemplate.""" """Prevent illegal characters in "name" field for PartParameterTemplate."""
for c in "!@#$%^&*()<>{}[].,?/\\|~`_+-=\'\"": # noqa: P103 for c in "\"\'`!?|": # noqa: P103
if c in str(name): if c in str(name):
raise ValidationError(_(f"Illegal character in template name ({c})")) 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) 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): class PartParameter(models.Model):
"""A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part. """A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part.
+3
View File
@@ -75,6 +75,7 @@ class CategorySerializer(InvenTreeModelSerializer):
'pathstring', 'pathstring',
'starred', 'starred',
'url', 'url',
'icon',
] ]
@@ -88,6 +89,7 @@ class CategoryTree(InvenTreeModelSerializer):
'pk', 'pk',
'name', 'name',
'parent', 'parent',
'icon',
] ]
@@ -238,6 +240,7 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
'pk', 'pk',
'name', 'name',
'units', 'units',
'description',
] ]
+9 -2
View File
@@ -1,6 +1,7 @@
{% extends "part/part_app_base.html" %} {% extends "part/part_app_base.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block sidebar %} {% block sidebar %}
{% include 'part/category_sidebar.html' %} {% include 'part/category_sidebar.html' %}
@@ -12,7 +13,12 @@
{% block heading %} {% block heading %}
{% if category %} {% 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 %} {% else %}
{% trans "Parts" %} {% trans "Parts" %}
{% endif %} {% endif %}
@@ -288,7 +294,8 @@
node.href = `/part/category/${node.pk}/`; node.href = `/part/category/${node.pk}/`;
return node; return node;
} },
defaultIcon: global_settings.PART_CATEGORY_DEFAULT_ICON,
}); });
onPanelLoad('subcategories', function() { onPanelLoad('subcategories', function() {
+15 -1
View File
@@ -40,6 +40,11 @@
<div class='d-flex flex-wrap'> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Scheduling" %}</h4> <h4>{% trans "Part Scheduling" %}</h4>
{% include "spacer.html" %} {% 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> </div>
<div class='panel-content'> <div class='panel-content'>
@@ -427,7 +432,16 @@
// Load the "scheduling" tab // Load the "scheduling" tab
onPanelLoad('scheduling', function() { 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 // Load the "suppliers" tab
@@ -4,3 +4,15 @@
<div id='part-schedule' style='max-height: 300px;'> <div id='part-schedule' style='max-height: 300px;'>
<canvas id='part-schedule-chart' width='100%' style='max-height: 300px;'></canvas> <canvas id='part-schedule-chart' width='100%' style='max-height: 300px;'></canvas>
</div> </div>
<table class='table table-striped table-condensed' id='part-schedule-table'>
<thead>
<tr>
<th>{% trans "Link" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Scheduled Quantity" %}</th>
</tr>
</thead>
<tbody></tbody>
</table>
@@ -17,6 +17,7 @@ import InvenTree.helpers
from common.models import ColorTheme, InvenTreeSetting, InvenTreeUserSetting from common.models import ColorTheme, InvenTreeSetting, InvenTreeUserSetting
from common.settings import currency_code_default from common.settings import currency_code_default
from InvenTree import settings, version from InvenTree import settings, version
from plugin import registry
from plugin.models import NotificationUserSetting, PluginSetting from plugin.models import NotificationUserSetting, PluginSetting
register = template.Library() register = template.Library()
@@ -149,6 +150,25 @@ def plugins_enabled(*args, **kwargs):
return djangosettings.PLUGINS_ENABLED 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() @register.simple_tag()
def inventree_db_engine(*args, **kwargs): def inventree_db_engine(*args, **kwargs):
"""Return the InvenTree database backend e.g. 'postgresql'.""" """Return the InvenTree database backend e.g. 'postgresql'."""
@@ -175,7 +195,7 @@ def inventree_title(*args, **kwargs):
@register.simple_tag() @register.simple_tag()
def inventree_logo(**kwargs): 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 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) 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() @register.simple_tag()
def inventree_base_url(*args, **kwargs): def inventree_base_url(*args, **kwargs):
"""Return the INVENTREE_BASE_URL setting.""" """Return the INVENTREE_BASE_URL setting."""
+59 -21
View File
@@ -223,35 +223,73 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
self.assertEqual(PartCategoryParameterTemplate.objects.count(), 0) self.assertEqual(PartCategoryParameterTemplate.objects.count(), 0)
def test_bleach(self): 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}) url = reverse('api-part-category-detail', kwargs={'pk': 1})
self.patch( # Invalid values containing tags
url, invalid_values = [
{ '<img src="test"/>',
'description': '<img src=# onerror=alert("pwned")>', '<a href="#">Link</a>',
}, "<a href='#'>Link</a>",
expected_code=200 '<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.assertIn('Remove HTML tags', str(response.data))
self.assertEqual(cat.description, '&lt;img src=# onerror=alert("pwned")&gt;')
self.patch( # Raw characters should be allowed
url, allowed = [
{ '<< hello',
'description': '<a href="www.google.com">LINK</a><script>alert("h4x0r")</script>', 'Alpha & Omega',
}, 'A > B > C',
expected_code=200, ]
)
# Tags must have been bleached out for val in allowed:
cat.refresh_from_db() 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): class PartOptionsAPITest(InvenTreeAPITestCase):
-3
View File
@@ -28,9 +28,6 @@ part_detail_urls = [
category_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 # Category detail views
re_path(r'(?P<pk>\d+)/', views.CategoryDetail.as_view(), name='category-detail'), re_path(r'(?P<pk>\d+)/', views.CategoryDetail.as_view(), name='category-detail'),
] ]
+1 -1
View File
@@ -52,7 +52,7 @@ class PluginConfigAdmin(admin.ModelAdmin):
"""Custom admin with restricted id fields.""" """Custom admin with restricted id fields."""
readonly_fields = ["key", "name", ] readonly_fields = ["key", "name", ]
list_display = ['name', 'key', '__str__', 'active', ] list_display = ['name', 'key', '__str__', 'active', 'is_sample']
list_filter = ['active'] list_filter = ['active']
actions = [plugin_activate, plugin_deactivate, ] actions = [plugin_activate, plugin_deactivate, ]
inlines = [PluginSettingInline, ] inlines = [PluginSettingInline, ]
+1 -1
View File
@@ -43,7 +43,7 @@ class PluginAppConfig(AppConfig):
pass pass
# get plugins and init them # get plugins and init them
registry.collect_plugins() registry.plugin_modules = registry.collect_plugins()
registry.load_plugins() registry.load_plugins()
# drop out of maintenance # drop out of maintenance
@@ -1,7 +1,7 @@
"""Core set of Notifications as a Plugin.""" """Core set of Notifications as a Plugin."""
from django.template.loader import render_to_string 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 from allauth.account.models import EmailAddress
+19 -10
View File
@@ -7,6 +7,7 @@ import pkgutil
import subprocess import subprocess
import sysconfig import sysconfig
import traceback import traceback
from importlib.metadata import entry_points
from django import template from django import template
from django.conf import settings 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] package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
except ValueError: except ValueError:
# is file - loaded -> form a name for that # is file - loaded -> form a name for that
path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR) try:
path_parts = [*path_obj.parts] path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix path_parts = [*path_obj.parts]
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
# remove path prefixes # remove path prefixes
if path_parts[0] == 'plugin': if path_parts[0] == 'plugin':
path_parts.remove('plugin') path_parts.remove('plugin')
path_parts.pop(0) path_parts.pop(0)
else: else:
path_parts.remove('plugins') # pragma: no cover 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: if do_log:
log_kwargs = {} 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): if settings.TESTING_ENV and package_name != 'integration.broken_sample' and isinstance(error, IntegrityError):
raise error # pragma: no cover raise error # pragma: no cover
raise IntegrationPluginError(package_name, str(error)) raise IntegrationPluginError(package_name, str(error))
def get_entrypoints():
"""Returns list for entrypoints for InvenTree plugins."""
return entry_points().get('inventree_plugins', [])
# endregion # endregion
View File
+10
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"
+25 -3
View File
@@ -3,6 +3,7 @@
import warnings import warnings
from django.conf import settings from django.conf import settings
from django.contrib import admin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -123,11 +124,11 @@ class PluginConfig(models.Model):
self.__org_active = self.active self.__org_active = self.active
# append settings from registry # 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): def get_plugin_meta(name):
if self.plugin: if plugin:
return str(getattr(self.plugin, name, None)) return str(getattr(plugin, name, None))
return None return None
self.meta = { self.meta = {
@@ -136,6 +137,9 @@ class PluginConfig(models.Model):
'package_path', 'settings_url', ] 'package_path', 'settings_url', ]
} }
# Save plugin
self.plugin: InvenTreePlugin = plugin
def save(self, force_insert=False, force_update=False, *args, **kwargs): def save(self, force_insert=False, force_update=False, *args, **kwargs):
"""Extend save method to reload plugins if the 'active' status changes.""" """Extend save method to reload plugins if the 'active' status changes."""
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
@@ -151,10 +155,26 @@ class PluginConfig(models.Model):
return ret 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): class PluginSetting(common.models.BaseInvenTreeSetting):
"""This model represents settings for individual plugins.""" """This model represents settings for individual plugins."""
typ = 'plugin'
class Meta: class Meta:
"""Meta for PluginSetting.""" """Meta for PluginSetting."""
unique_together = [ unique_together = [
@@ -204,6 +224,8 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
class NotificationUserSetting(common.models.BaseInvenTreeSetting): class NotificationUserSetting(common.models.BaseInvenTreeSetting):
"""This model represents notification settings for a user.""" """This model represents notification settings for a user."""
typ = 'notification'
class Meta: class Meta:
"""Meta for NotificationUserSetting.""" """Meta for NotificationUserSetting."""
unique_together = [ unique_together = [
+73 -25
View File
@@ -2,11 +2,11 @@
import inspect import inspect
import logging import logging
import os
import pathlib
import warnings import warnings
from datetime import datetime 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.conf import settings
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
@@ -171,7 +171,24 @@ class MixinBase:
return mixins 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. """The InvenTreePlugin class is used to integrate with 3rd party software.
DO NOT USE THIS DIRECTLY, USE plugin.InvenTreePlugin DO NOT USE THIS DIRECTLY, USE plugin.InvenTreePlugin
@@ -191,11 +208,18 @@ class InvenTreePlugin(MixinBase, MetaBase):
""" """
super().__init__() super().__init__()
self.add_mixin('base') self.add_mixin('base')
self.def_path = inspect.getfile(self.__class__)
self.path = os.path.dirname(self.def_path)
self.define_package() 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: def _get_value(self, meta_name: str, package_name: str) -> str:
"""Extract values from class meta or package info. """Extract values from class meta or package info.
@@ -243,43 +267,54 @@ class InvenTreePlugin(MixinBase, MetaBase):
@property @property
def version(self): def version(self):
"""Version of plugin.""" """Version of plugin."""
version = self._get_value('VERSION', 'version') return self._get_value('VERSION', 'version')
return version
@property @property
def website(self): def website(self):
"""Website of plugin - if set else None.""" """Website of plugin - if set else None."""
website = self._get_value('WEBSITE', 'website') return self._get_value('WEBSITE', 'website')
return website
@property @property
def license(self): def license(self):
"""License of plugin.""" """License of plugin."""
lic = self._get_value('LICENSE', 'license') return self._get_value('LICENSE', 'license')
return lic
# endregion # endregion
@classmethod
def check_is_package(cls):
"""Is the plugin delivered as a package."""
return getattr(cls, 'is_package', False)
@property @property
def _is_package(self): def _is_package(self):
"""Is the plugin delivered as a package.""" """Is the plugin delivered as a package."""
return getattr(self, 'is_package', False) return getattr(self, 'is_package', False)
@property @classmethod
def is_sample(self): def check_is_sample(cls) -> bool:
"""Is this plugin part of the samples?""" """Is this plugin part of the samples?"""
path = str(self.package_path) return str(cls.check_package_path()).startswith('plugin/samples/')
return 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 @property
def package_path(self): def package_path(self):
"""Path to the plugin.""" """Path to the plugin."""
if self._is_package: return self.check_package_path()
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)
@property @property
def settings_url(self): def settings_url(self):
@@ -289,12 +324,25 @@ class InvenTreePlugin(MixinBase, MetaBase):
# region package info # region package info
def _get_package_commit(self): def _get_package_commit(self):
"""Get last git commit for the plugin.""" """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 @classmethod
def _get_package_metadata(cls): def _get_package_metadata(cls):
"""Get package metadata for plugin.""" """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 { return {
'author': meta['Author-email'], 'author': meta['Author-email'],
+109 -89
View File
@@ -4,13 +4,14 @@
- Manages setup and teardown of plugin class instances - Manages setup and teardown of plugin class instances
""" """
import imp
import importlib import importlib
import logging import logging
import os import os
import subprocess import subprocess
from importlib import metadata, reload from importlib import reload
from pathlib import Path from pathlib import Path
from typing import OrderedDict from typing import Dict, List, OrderedDict
from django.apps import apps from django.apps import apps
from django.conf import settings 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 InvenTree.config import get_setting
from .helpers import (IntegrationPluginError, get_plugins, handle_error, from .helpers import (IntegrationPluginError, get_entrypoints, get_plugins,
log_error) handle_error, log_error)
from .plugin import InvenTreePlugin from .plugin import InvenTreePlugin
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@@ -40,19 +41,20 @@ class PluginsRegistry:
Set up all needed references for internal and external states. Set up all needed references for internal and external states.
""" """
# plugin registry # plugin registry
self.plugins = {} self.plugins: Dict[str, InvenTreePlugin] = {} # List of active instances
self.plugins_inactive = {} 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 # flags
self.is_loading = False self.is_loading = False # Are plugins beeing loaded right now
self.apps_loading = True # Marks if apps were reloaded yet self.apps_loading = True # Marks if apps were reloaded yet
self.git_is_modern = True # Is a modern version of git available 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 # mixins
self.mixins_settings = {} self.mixins_settings = {}
@@ -200,7 +202,7 @@ class PluginsRegistry:
if settings.TESTING: if settings.TESTING:
custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None) custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None)
else: else: # pragma: no cover
custom_dirs = get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir') custom_dirs = get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir')
# Load from user specified directories (unless in testing mode) # Load from user specified directories (unless in testing mode)
@@ -215,7 +217,7 @@ class PluginsRegistry:
if not pd.exists(): if not pd.exists():
try: try:
pd.mkdir(exist_ok=True) pd.mkdir(exist_ok=True)
except Exception: except Exception: # pragma: no cover
logger.error(f"Could not create plugin directory '{pd}'") logger.error(f"Could not create plugin directory '{pd}'")
continue continue
@@ -225,24 +227,31 @@ class PluginsRegistry:
if not init_filename.exists(): if not init_filename.exists():
try: try:
init_filename.write_text("# InvenTree plugin directory\n") init_filename.write_text("# InvenTree plugin directory\n")
except Exception: except Exception: # pragma: no cover
logger.error(f"Could not create file '{init_filename}'") logger.error(f"Could not create file '{init_filename}'")
continue continue
# By this point, we have confirmed that the directory at least exists
if pd.exists() and pd.is_dir(): if pd.exists() and pd.is_dir():
# By this point, we have confirmed that the directory at least exists # Convert to python dot-path
logger.info(f"Added plugin directory: '{pd}'") if pd.is_relative_to(settings.BASE_DIR):
dirs.append(pd) 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 return dirs
def collect_plugins(self): 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: if not settings.PLUGINS_ENABLED:
# Plugins not enabled, do nothing # Plugins not enabled, do nothing
return # pragma: no cover return # pragma: no cover
self.plugin_modules = [] # clear collected_plugins = []
# Collect plugins from paths # Collect plugins from paths
for plugin in self.plugin_dirs(): for plugin in self.plugin_dirs():
@@ -258,26 +267,33 @@ class PluginsRegistry:
parent_path = str(parent_obj.parent) parent_path = str(parent_obj.parent)
plugin = parent_obj.name 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: 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 # 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): if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP):
# Collect plugins from setup entry points # Collect plugins from setup entry points
for entry in metadata.entry_points().get('inventree_plugins', []): # pragma: no cover for entry in get_entrypoints():
try: try:
plugin = entry.load() plugin = entry.load()
plugin.is_package = True plugin.is_package = True
plugin._get_package_metadata() plugin._get_package_metadata()
self.plugin_modules.append(plugin) collected_plugins.append(plugin)
except Exception as error: except Exception as error: # pragma: no cover
handle_error(error, do_raise=False, log_name='discovery') handle_error(error, do_raise=False, log_name='discovery')
# Log collected plugins # Log collected plugins
logger.info(f'Collected {len(self.plugin_modules)} plugins!') logger.info(f'Collected {len(collected_plugins)} plugins!')
logger.info(", ".join([a.__module__ for a in self.plugin_modules])) logger.info(", ".join([a.__module__ for a in collected_plugins]))
return collected_plugins
def install_plugin_file(self): def install_plugin_file(self):
"""Make sure all plugins are installed in the current enviroment.""" """Make sure all plugins are installed in the current enviroment."""
@@ -324,74 +340,77 @@ class PluginsRegistry:
# endregion # endregion
# region general internal loading /activating / deactivating / deloading # region general internal loading /activating / deactivating / deloading
def _init_plugins(self, disabled=None): def _init_plugins(self, disabled: str = None):
"""Initialise all found plugins. """Initialise all found plugins.
:param disabled: loading path of disabled app, defaults to None Args:
:type disabled: str, optional disabled (str, optional): Loading path of disabled app. Defaults to None.
:raises error: IntegrationPluginError
Raises:
error: IntegrationPluginError
""" """
from plugin.models import PluginConfig 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') logger.info('Starting plugin initialisation')
# Initialize plugins # Initialize plugins
for plugin in self.plugin_modules: for plg in self.plugin_modules:
# Check if package
was_packaged = getattr(plugin, 'is_package', False)
# Check if activated
# These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!! # These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
plug_name = plugin.NAME plg_name = plg.NAME
plug_key = plugin.SLUG if getattr(plugin, 'SLUG', None) else plug_name plg_key = slugify(plg.SLUG if getattr(plg, 'SLUG', None) else plg_name) # keys are slugs!
plug_key = slugify(plug_key) # keys are slugs!
try: 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: except (OperationalError, ProgrammingError) as error:
# Exception if the database has not been migrated yet - check if test are running - raise if not # Exception if the database has not been migrated yet - check if test are running - raise if not
if not settings.PLUGIN_TESTING: if not settings.PLUGIN_TESTING:
raise error # pragma: no cover raise error # pragma: no cover
plugin_db_setting = None plg_db = None
except (IntegrityError) as error: # pragma: no cover 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 # Always activate if testing
if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active): if settings.PLUGIN_TESTING or (plg_db and plg_db.active):
# Check if the plugin was blocked -> threw an error # Check if the plugin was blocked -> threw an error; option1: package, option2: file-based
if disabled: if disabled and ((plg.__name__ == disabled) or (plg.__module__ == disabled)):
# option1: package, option2: file-based safe_reference(plugin=plg, key=plg_key, active=False)
if (plugin.__name__ == disabled) or (plugin.__module__ == disabled): continue # continue -> the plugin is not loaded
# 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}')
# Initialize package - we can be sure that an admin has activated the plugin
logger.info(f'Loading plugin `{plg_name}`')
try: try:
plugin = plugin() plg_i: InvenTreePlugin = plg()
logger.info(f'Loaded plugin `{plg_name}`')
except Exception as error: except Exception as error:
# log error and raise it -> disable plugin handle_error(error, log_name='init') # log error and raise it -> disable plugin
handle_error(error, log_name='init')
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 # Run version check for plugin
if (plg_i.MIN_VERSION or plg_i.MAX_VERSION) and not plg_i.check_version():
if plugin_db_setting: safe_reference(plugin=plg_i, key=plg_key, active=False)
plugin.pk = plugin_db_setting.pk else:
safe_reference(plugin=plg_i, key=plg_key)
# safe reference else: # pragma: no cover
self.plugins[plugin.slug] = plugin safe_reference(plugin=plg, key=plg_key, active=False)
else:
# save for later reference
self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover
def _activate_plugins(self, force_reload=False, full_reload: bool = False): def _activate_plugins(self, force_reload=False, full_reload: bool = False):
"""Run activation functions for all plugins. """Run activation functions for all plugins.
@@ -568,10 +587,10 @@ class PluginsRegistry:
""" """
try: try:
# for local path plugins # 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 except ValueError: # pragma: no cover
# plugin is shipped as package # plugin is shipped as package - extract plugin module name
plugin_path = plugin.NAME plugin_path = plugin.__module__.split('.')[0]
return plugin_path return plugin_path
def deactivate_plugin_app(self): def deactivate_plugin_app(self):
@@ -625,24 +644,25 @@ class PluginsRegistry:
self.installed_apps = [] self.installed_apps = []
def _clean_registry(self): def _clean_registry(self):
# remove all plugins from registry """Remove all plugins from registry."""
self.plugins = {} self.plugins: Dict[str, InvenTreePlugin] = {}
self.plugins_inactive = {} self.plugins_inactive: Dict[str, InvenTreePlugin] = {}
self.plugins_full: Dict[str, InvenTreePlugin] = {}
def _update_urls(self): 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 InvenTree.urls import urlpatterns as global_pattern
from plugin.urls import get_plugin_urls from plugin.urls import get_plugin_urls
for index, a in enumerate(urlpatterns): for index, url in enumerate(urlpattern):
if hasattr(a, 'app_name'): if hasattr(url, 'app_name'):
if a.app_name == 'admin': if url.app_name == 'admin':
urlpatterns[index] = re_path(r'^admin/', admin.site.urls, name='inventree-admin') urlpattern[index] = re_path(r'^admin/', admin.site.urls, name='inventree-admin')
elif a.app_name == 'plugin': elif url.app_name == 'plugin':
urlpatterns[index] = get_plugin_urls() urlpattern[index] = get_plugin_urls()
# replace frontendpatterns # Replace frontendpatterns
global_pattern[0] = re_path('', include(urlpatterns)) global_pattern[0] = re_path('', include(urlpattern))
clear_url_caches() clear_url_caches()
def _reload_apps(self, force_reload: bool = False, full_reload: bool = False): def _reload_apps(self, force_reload: bool = False, full_reload: bool = False):
@@ -678,7 +698,7 @@ class PluginsRegistry:
# endregion # endregion
registry = PluginsRegistry() registry: PluginsRegistry = PluginsRegistry()
def call_function(plugin_name, function_name, *args, **kwargs): def call_function(plugin_name, function_name, *args, **kwargs):
@@ -0,0 +1,9 @@
"""Sample plugin for versioning."""
from plugin import InvenTreePlugin
class VersionPlugin(InvenTreePlugin):
"""A small version sample."""
NAME = "version"
MAX_VERSION = '0.1.0'
+2 -4
View File
@@ -1,7 +1,5 @@
"""Load templates for loaded plugins.""" """Load templates for loaded plugins."""
from pathlib import Path
from django.template.loaders.filesystem import Loader as FilesystemLoader from django.template.loaders.filesystem import Loader as FilesystemLoader
from plugin import registry from plugin import registry
@@ -26,8 +24,8 @@ class PluginTemplateLoader(FilesystemLoader):
template_dirs = [] template_dirs = []
for plugin in registry.plugins.values(): for plugin in registry.plugins.values():
new_path = Path(plugin.path) / dirname new_path = plugin.path().joinpath(dirname)
if Path(new_path).is_dir(): if new_path.is_dir():
template_dirs.append(new_path) template_dirs.append(new_path)
return tuple(template_dirs) return tuple(template_dirs)

Some files were not shown because too many files have changed in this diff Show More