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:
@@ -0,0 +1,47 @@
|
|||||||
|
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.241.1/containers/python-3/.devcontainer/base.Dockerfile
|
||||||
|
|
||||||
|
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
|
||||||
|
ARG VARIANT="3.10-bullseye"
|
||||||
|
FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
|
||||||
|
|
||||||
|
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||||
|
ARG NODE_VERSION="none"
|
||||||
|
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||||
|
|
||||||
|
# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image.
|
||||||
|
# COPY requirements.txt /tmp/pip-tmp/
|
||||||
|
# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \
|
||||||
|
# && rm -rf /tmp/pip-tmp
|
||||||
|
|
||||||
|
# [Optional] Uncomment this section to install additional OS packages.
|
||||||
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \
|
||||||
|
apt-get -y install --no-install-recommends \
|
||||||
|
git gcc g++ gettext gnupg libffi-dev \
|
||||||
|
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
|
||||||
|
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
|
||||||
|
# Image format support
|
||||||
|
libjpeg-dev webp \
|
||||||
|
# SQLite support
|
||||||
|
sqlite3 \
|
||||||
|
# PostgreSQL support
|
||||||
|
libpq-dev \
|
||||||
|
# MySQL / MariaDB support
|
||||||
|
default-libmysqlclient-dev mariadb-client && \
|
||||||
|
apt-get autoclean && apt-get autoremove
|
||||||
|
|
||||||
|
# [Optional] Uncomment this line to install global node packages.
|
||||||
|
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||||
|
|
||||||
|
# Update pip
|
||||||
|
RUN pip install --upgrade pip
|
||||||
|
|
||||||
|
# Install required base-level python packages
|
||||||
|
COPY ./docker/requirements.txt base_requirements.txt
|
||||||
|
RUN pip install --disable-pip-version-check -U -r base_requirements.txt
|
||||||
|
|
||||||
|
# preserve command history between container starts
|
||||||
|
# Ref: https://code.visualstudio.com/remote/advancedcontainers/persist-bash-history
|
||||||
|
# Folder will be created in 'postCreateCommand' in devcontainer.json as it's not preserved due to the bind mount
|
||||||
|
RUN echo "export PROMPT_COMMAND='history -a' && export HISTFILE=/workspaces/InvenTree/dev/commandhistory/.bash_history" >> "/home/vscode/.bashrc"
|
||||||
|
|
||||||
|
WORKDIR /workspaces/InvenTree
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||||
|
// https://github.com/microsoft/vscode-dev-containers/tree/v0.241.1/containers/python-3
|
||||||
|
{
|
||||||
|
"name": "InvenTree",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": "..",
|
||||||
|
"args": {
|
||||||
|
// Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6
|
||||||
|
// Append -bullseye or -buster to pin to an OS version.
|
||||||
|
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||||
|
"VARIANT": "3.10-bullseye",
|
||||||
|
// Options
|
||||||
|
"NODE_VERSION": "lts/*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
// Configure properties specific to VS Code.
|
||||||
|
"vscode": {
|
||||||
|
// Set *default* container specific settings.json values on container create.
|
||||||
|
"settings": {
|
||||||
|
"python.defaultInterpreterPath": "/workspaces/InvenTree/dev/venv/bin/python",
|
||||||
|
"python.linting.enabled": true,
|
||||||
|
"python.linting.pylintEnabled": false,
|
||||||
|
"python.linting.flake8Enabled": true,
|
||||||
|
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
|
||||||
|
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||||
|
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
|
||||||
|
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
|
||||||
|
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
|
||||||
|
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
|
||||||
|
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
|
||||||
|
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
|
||||||
|
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add the IDs of extensions you want installed when the container is created.
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"batisteo.vscode-django"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
"forwardPorts": [8000],
|
||||||
|
"portsAttributes": {
|
||||||
|
"8000": {
|
||||||
|
"label": "InvenTree server"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
"postCreateCommand": "./.devcontainer/postCreateCommand.sh",
|
||||||
|
|
||||||
|
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||||
|
"remoteUser": "vscode",
|
||||||
|
"features": {
|
||||||
|
"git": "os-provided",
|
||||||
|
"github-cli": "latest"
|
||||||
|
},
|
||||||
|
|
||||||
|
"remoteEnv": {
|
||||||
|
// Inventree config
|
||||||
|
"INVENTREE_DEBUG": "True",
|
||||||
|
"INVENTREE_DEBUG_LEVEL": "INFO",
|
||||||
|
"INVENTREE_DB_ENGINE": "sqlite3",
|
||||||
|
"INVENTREE_DB_NAME": "/workspaces/InvenTree/dev/database.sqlite3",
|
||||||
|
"INVENTREE_MEDIA_ROOT": "/workspaces/InvenTree/dev/media",
|
||||||
|
"INVENTREE_STATIC_ROOT": "/workspaces/InvenTree/dev/static",
|
||||||
|
"INVENTREE_CONFIG_FILE": "/workspaces/InvenTree/dev/config.yaml",
|
||||||
|
"INVENTREE_SECRET_KEY_FILE": "/workspaces/InvenTree/dev/secret_key.txt",
|
||||||
|
"INVENTREE_PLUGIN_DIR": "/workspaces/InvenTree/dev/plugins",
|
||||||
|
"INVENTREE_PLUGIN_FILE": "/workspaces/InvenTree/dev/plugins.txt",
|
||||||
|
|
||||||
|
// Python config
|
||||||
|
"PIP_USER": "no",
|
||||||
|
|
||||||
|
// used to load the venv into the PATH and avtivate it
|
||||||
|
// Ref: https://stackoverflow.com/a/56286534
|
||||||
|
"VIRTUAL_ENV": "/workspaces/InvenTree/dev/venv",
|
||||||
|
"PATH": "/workspaces/InvenTree/dev/venv/bin:${containerEnv:PATH}"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# create folders
|
||||||
|
mkdir -p /workspaces/InvenTree/dev/{commandhistory,plugins}
|
||||||
|
cd /workspaces/InvenTree
|
||||||
|
|
||||||
|
# create venv
|
||||||
|
python3 -m venv dev/venv
|
||||||
|
. dev/venv/bin/activate
|
||||||
|
|
||||||
|
# setup inventree server
|
||||||
|
pip install invoke
|
||||||
|
inv update
|
||||||
|
inv setup-dev
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug
|
|
||||||
about: Create a bug report to help us improve InvenTree!
|
|
||||||
title: "[BUG] Enter bug description"
|
|
||||||
labels: bug, question
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<!---
|
|
||||||
Everything inside these brackets is hidden - please remove them where you fill out information.
|
|
||||||
--->
|
|
||||||
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
<!---
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
--->
|
|
||||||
|
|
||||||
**Steps to Reproduce**
|
|
||||||
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
<!---
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
--->
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
<!---
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
--->
|
|
||||||
|
|
||||||
<!---
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
--->
|
|
||||||
|
|
||||||
**Deployment Method**
|
|
||||||
- [ ] Docker
|
|
||||||
- [ ] Bare Metal
|
|
||||||
|
|
||||||
**Version Information**
|
|
||||||
<!---
|
|
||||||
You can get this by going to the "About InvenTree" section in the upper right corner and clicking on to the "copy version information"
|
|
||||||
--->
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: "Bug"
|
||||||
|
description: "Create a bug report to help us improve InvenTree!"
|
||||||
|
labels: ["bug", "question", "triage:not-checked"]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: no-duplicate-issues
|
||||||
|
attributes:
|
||||||
|
label: "Please verify that this bug has NOT been raised before."
|
||||||
|
description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=)"
|
||||||
|
options:
|
||||||
|
- label: "I checked and didn't find similar issue"
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "Describe the bug*"
|
||||||
|
description: "A clear and concise description of what the bug is."
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "Steps to Reproduce"
|
||||||
|
description: "Steps to reproduce the behavior, please make it detailed"
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "Expected behavior"
|
||||||
|
description: "A clear and concise description of what you expected to happen."
|
||||||
|
placeholder: "..."
|
||||||
|
- type: checkboxes
|
||||||
|
id: deployment
|
||||||
|
attributes:
|
||||||
|
label: "Deployment Method"
|
||||||
|
options:
|
||||||
|
- label: "Docker"
|
||||||
|
- label: "Bare metal"
|
||||||
|
- type: textarea
|
||||||
|
id: version-info
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "Version Information"
|
||||||
|
description: "The version info block."
|
||||||
|
placeholder: "You can get this by going to the `About InvenTree` section in the upper right corner and clicking on to the `copy version information`"
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: "Relevant log output"
|
||||||
|
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: "[FR]"
|
|
||||||
labels: enhancement
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request the result of a bug?**
|
|
||||||
Please link it here.
|
|
||||||
|
|
||||||
**Problem**
|
|
||||||
A clear and concise description of what the problem is. e.g. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Suggested solution**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Examples of other systems**
|
|
||||||
Show how other software handles your FR if you have examples.
|
|
||||||
|
|
||||||
**Do you want to develop this?**
|
|
||||||
If so please describe briefly how you would like to implement it (so we can give advice) and if you have experience in the needed technology (you do not need to be a pro - this is just as a information for us).
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: "[FR] title"
|
||||||
|
labels: ["enhancement", "triage:not-checked"]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: no-duplicate-issues
|
||||||
|
attributes:
|
||||||
|
label: "Please verify that this feature request has NOT been suggested before."
|
||||||
|
description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=)"
|
||||||
|
options:
|
||||||
|
- label: "I checked and didn't find similar feature request"
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "Problem statement"
|
||||||
|
description: "A clear and concise description of what the solved problem or feature request is."
|
||||||
|
placeholder: "I am always struggeling with ..."
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "Suggested solution"
|
||||||
|
description: "A clear and concise description of what you want to happen."
|
||||||
|
placeholder: "In my use-case, ..."
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "Describe alternatives you've considered"
|
||||||
|
description: "A clear and concise description of any alternative solutions or features you've considered."
|
||||||
|
placeholder: "This could also be done by doing ..."
|
||||||
|
- type: textarea
|
||||||
|
id: examples
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
attributes:
|
||||||
|
label: "Examples of other systems"
|
||||||
|
description: "Show how other software handles your FR if you have examples."
|
||||||
|
placeholder: "I software xxx this is done in the following way..."
|
||||||
|
- type: checkboxes
|
||||||
|
id: self-develop
|
||||||
|
attributes:
|
||||||
|
label: "Do you want to develop this?"
|
||||||
|
description: "you do not need to be a pro - this is just as a information for us"
|
||||||
|
options:
|
||||||
|
- label: "I want to develop this."
|
||||||
|
required: false
|
||||||
@@ -21,10 +21,9 @@ jobs:
|
|||||||
INVENTREE_MEDIA_ROOT: ./media
|
INVENTREE_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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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,14 +82,14 @@ 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
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -6,12 +6,32 @@ on:
|
|||||||
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 }}
|
||||||
@@ -21,7 +41,7 @@ jobs:
|
|||||||
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 }}
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ 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
|
||||||
|
important.'
|
||||||
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
||||||
stale-issue-label: 'inactive'
|
stale-issue-label: 'inactive'
|
||||||
stale-pr-label: 'inactive'
|
stale-pr-label: 'inactive'
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ 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: |
|
||||||
|
|||||||
+8
-1
@@ -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/
|
||||||
|
|||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "InvenTree Server",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/InvenTree/manage.py",
|
||||||
|
"args": ["runserver"],
|
||||||
|
"django": true,
|
||||||
|
"justMyCode": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python: Django - 3rd party",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/InvenTree/manage.py",
|
||||||
|
"args": ["runserver"],
|
||||||
|
"django": true,
|
||||||
|
"justMyCode": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
// for the documentation about the tasks.json format
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "clean-settings",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "inv clean-settings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "delete-data",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "inv delete-data",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "migrate",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "inv migrate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "server",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "inv server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "setup-dev",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "inv setup-dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "setup-test",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "inv setup-test --path dev/inventree-demo-dataset",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "superuser",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "inv superuser",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "test",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "inv test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "update",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "inv update",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
+4
-1
@@ -147,7 +147,7 @@ Any user-facing strings *must* be passed through the translation engine.
|
|||||||
For strings exposed via Python code, use the following format:
|
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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
'>': '>',
|
||||||
|
'<': '<',
|
||||||
|
'&': '&',
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}'")
|
||||||
|
|||||||
@@ -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 |
@@ -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
@@ -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."""
|
||||||
|
|||||||
@@ -674,10 +674,8 @@ 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(
|
items = StockModels.StockItem.objects.filter(
|
||||||
part=part,
|
part__in=parts,
|
||||||
serial=str(serial),
|
serial=str(serial),
|
||||||
quantity=1,
|
quantity=1,
|
||||||
).filter(StockModels.StockItem.IN_STOCK_FILTER)
|
).filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -455,7 +455,9 @@ function loadUntrackedStockTable() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPanelLoad('allocate', function() {
|
||||||
loadUntrackedStockTable();
|
loadUntrackedStockTable();
|
||||||
|
});
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -158,16 +158,12 @@ class LabelPrintMixin:
|
|||||||
|
|
||||||
pages = []
|
pages = []
|
||||||
|
|
||||||
if len(outputs) > 1:
|
|
||||||
# If more than one output is generated, merge them into a single file
|
|
||||||
for output in outputs:
|
for output in outputs:
|
||||||
doc = output.get_document()
|
doc = output.get_document()
|
||||||
for page in doc.pages:
|
for page in doc.pages:
|
||||||
pages.append(page)
|
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>
|
||||||
|
<div class='content'>
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Label data rendered here! -->
|
<!-- Label data rendered here! -->
|
||||||
{% endblock %}
|
{% 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1535
-1413
File diff suppressed because it is too large
Load Diff
+1527
-1405
File diff suppressed because it is too large
Load Diff
+1554
-1432
File diff suppressed because it is too large
Load Diff
+1580
-1442
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1522
-1400
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1524
-1402
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1530
-1408
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1530
-1408
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1595
-1473
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1520
-1398
File diff suppressed because it is too large
Load Diff
+1522
-1400
File diff suppressed because it is too large
Load Diff
+45
-6
@@ -1,10 +1,13 @@
|
|||||||
"""JSON API for the Order app."""
|
"""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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
{% if order.lines.count == 0 and order.status == PurchaseOrderStatus.PENDING %}
|
|
||||||
supplier: {
|
|
||||||
},
|
|
||||||
{% endif %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+85
-35
@@ -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,23 +474,23 @@ 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,
|
||||||
|
'speculative_quantity': speculative_quantity,
|
||||||
'title': title,
|
'title': title,
|
||||||
'label': label,
|
'label': label,
|
||||||
'url': url,
|
'url': url,
|
||||||
@@ -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,
|
|
||||||
|
# Track all outstanding build orders
|
||||||
|
seen_builds = set()
|
||||||
|
|
||||||
|
for bom_item in bom_items:
|
||||||
|
# Find a list of active builds for this BomItem
|
||||||
|
|
||||||
|
if bom_item.inherited:
|
||||||
|
# An "inherited" BOM item filters down to variant parts also
|
||||||
|
childs = bom_item.part.get_descendants(include_self=True)
|
||||||
|
builds = Build.objects.filter(
|
||||||
|
status__in=BuildStatus.ACTIVE_CODES,
|
||||||
|
part__in=childs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
builds = Build.objects.filter(
|
||||||
|
status__in=BuildStatus.ACTIVE_CODES,
|
||||||
|
part=bom_item.part,
|
||||||
)
|
)
|
||||||
|
|
||||||
for allocation in build_allocations:
|
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(
|
add_schedule_entry(
|
||||||
allocation.build.target_date,
|
build.target_date,
|
||||||
-allocation.quantity,
|
-part_allocated_quantity,
|
||||||
_('Stock required for Build Order'),
|
_('Stock required for Build Order'),
|
||||||
str(allocation.build),
|
str(build),
|
||||||
allocation.build.get_absolute_url(),
|
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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
+50
-12
@@ -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
|
||||||
|
invalid_values = [
|
||||||
|
'<img src="test"/>',
|
||||||
|
'<a href="#">Link</a>',
|
||||||
|
"<a href='#'>Link</a>",
|
||||||
|
'<b>',
|
||||||
|
]
|
||||||
|
|
||||||
|
for v in invalid_values:
|
||||||
|
response = self.patch(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'description': '<img src=# onerror=alert("pwned")>',
|
'description': v
|
||||||
},
|
},
|
||||||
expected_code=200
|
expected_code=400
|
||||||
)
|
)
|
||||||
|
|
||||||
cat = PartCategory.objects.get(pk=1)
|
self.assertIn('Remove HTML tags', str(response.data))
|
||||||
|
|
||||||
# Image tags have been stripped
|
# Raw characters should be allowed
|
||||||
self.assertEqual(cat.description, '<img src=# onerror=alert("pwned")>')
|
allowed = [
|
||||||
|
'<< hello',
|
||||||
|
'Alpha & Omega',
|
||||||
|
'A > B > C',
|
||||||
|
]
|
||||||
|
|
||||||
self.patch(
|
for val in allowed:
|
||||||
|
response = self.patch(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'description': '<a href="www.google.com">LINK</a><script>alert("h4x0r")</script>',
|
'description': val,
|
||||||
},
|
},
|
||||||
expected_code=200,
|
expected_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tags must have been bleached out
|
self.assertEqual(response.data['description'], val)
|
||||||
cat.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEqual(cat.description, '<a href="www.google.com">LINK</a><script>alert("h4x0r")</script>')
|
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):
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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, ]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +69,7 @@ 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
|
||||||
|
try:
|
||||||
path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
|
path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
|
||||||
path_parts = [*path_obj.parts]
|
path_parts = [*path_obj.parts]
|
||||||
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
|
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
|
||||||
@@ -80,6 +82,8 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Very simple sample plugin"""
|
||||||
|
|
||||||
|
from plugin import InvenTreePlugin
|
||||||
|
|
||||||
|
|
||||||
|
class SimplePlugin(InvenTreePlugin):
|
||||||
|
"""A very simple plugin."""
|
||||||
|
|
||||||
|
NAME = 'SimplePlugin'
|
||||||
|
SLUG = "simple"
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import warnings
|
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 = [
|
||||||
|
|||||||
+72
-24
@@ -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."""
|
||||||
|
|
||||||
|
# Try simple metadata lookup
|
||||||
|
try:
|
||||||
meta = metadata(cls.__name__)
|
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'],
|
||||||
|
|||||||
+103
-83
@@ -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,15 +41,16 @@ 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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
if pd.exists() and pd.is_dir():
|
|
||||||
# By this point, we have confirmed that the directory at least exists
|
# By this point, we have confirmed that the directory at least exists
|
||||||
logger.info(f"Added plugin directory: '{pd}'")
|
if pd.exists() and pd.is_dir():
|
||||||
dirs.append(pd)
|
# Convert to python dot-path
|
||||||
|
if pd.is_relative_to(settings.BASE_DIR):
|
||||||
|
pd_path = '.'.join(pd.relative_to(settings.BASE_DIR).parts)
|
||||||
|
else:
|
||||||
|
pd_path = str(pd)
|
||||||
|
|
||||||
|
# Add path
|
||||||
|
dirs.append(pd_path)
|
||||||
|
logger.info(f"Added plugin directory: '{pd}' as '{pd_path}'")
|
||||||
|
|
||||||
return dirs
|
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):
|
|
||||||
# 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
|
continue # continue -> the plugin is not loaded
|
||||||
|
|
||||||
# Initialize package
|
# Initialize package - we can be sure that an admin has activated the plugin
|
||||||
# now we can be sure that an admin has activated the plugin
|
logger.info(f'Loading plugin `{plg_name}`')
|
||||||
logger.info(f'Loading plugin {plug_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
|
|
||||||
|
|
||||||
# safe reference
|
|
||||||
self.plugins[plugin.slug] = plugin
|
|
||||||
else:
|
else:
|
||||||
# save for later reference
|
safe_reference(plugin=plg_i, key=plg_key)
|
||||||
self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover
|
else: # pragma: no cover
|
||||||
|
safe_reference(plugin=plg, key=plg_key, active=False)
|
||||||
|
|
||||||
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'
|
||||||
@@ -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
Reference in New Issue
Block a user