mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13:05:42 +00:00
Merge branch 'docupdates' of https://github.com/matmair/InvenTree into docupdates
This commit is contained in:
@ -1,4 +1,5 @@
|
|||||||
# InvenTree environment variables for a development setup
|
# InvenTree environment variables for a development setup
|
||||||
|
# These variables will be used by the docker-compose.yml file
|
||||||
|
|
||||||
# Set DEBUG to True for a development setup
|
# Set DEBUG to True for a development setup
|
||||||
INVENTREE_DEBUG=True
|
INVENTREE_DEBUG=True
|
69
.github/workflows/docker.yaml
vendored
Normal file
69
.github/workflows/docker.yaml
vendored
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Build, test and push InvenTree docker image
|
||||||
|
# This workflow runs under any of the following conditions:
|
||||||
|
#
|
||||||
|
# - Push to the master branch
|
||||||
|
# - Push to the stable branch
|
||||||
|
# - Publish release
|
||||||
|
#
|
||||||
|
# The following actions are performed:
|
||||||
|
#
|
||||||
|
# - Check that the version number matches the current branch or tag
|
||||||
|
# - Build the InvenTree docker image
|
||||||
|
# - Run suite of unit tests against the build image
|
||||||
|
# - Push the compiled, tested image to dockerhub
|
||||||
|
|
||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'master'
|
||||||
|
- 'stable'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
# Build the docker image
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Version Check
|
||||||
|
run: |
|
||||||
|
python3 ci/check_version_number.py
|
||||||
|
echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||||
|
echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: |
|
||||||
|
docker-compose build
|
||||||
|
docker-compose run inventree-dev-server invoke update
|
||||||
|
docker-compose up -d
|
||||||
|
docker-compose run inventree-dev-server invoke wait
|
||||||
|
docker-compose run inventree-dev-server invoke test
|
||||||
|
docker-compose down
|
||||||
|
- name: Set up QEMU
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
- name: Login to Dockerhub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Build and Push
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
push: true
|
||||||
|
target: production
|
||||||
|
tags: inventree/inventree:${{ env.docker_tag }}
|
||||||
|
build-args: commit_hash=${{ env.git_commit_hash }},commit_date=${{ env.git_commit_date }},commit_tag=${{ env.docker_tag }}
|
51
.github/workflows/docker_latest.yaml
vendored
51
.github/workflows/docker_latest.yaml
vendored
@ -1,51 +0,0 @@
|
|||||||
# Build and push latest docker image on push to master branch
|
|
||||||
|
|
||||||
name: Docker Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'master'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Check version number
|
|
||||||
run: |
|
|
||||||
python3 ci/check_version_number.py --dev
|
|
||||||
- name: Build Docker Image
|
|
||||||
run: |
|
|
||||||
cd docker
|
|
||||||
docker-compose build
|
|
||||||
docker-compose run inventree-dev-server invoke update
|
|
||||||
- name: Run unit tests
|
|
||||||
run: |
|
|
||||||
cd docker
|
|
||||||
docker-compose up -d
|
|
||||||
docker-compose run inventree-dev-server invoke wait
|
|
||||||
docker-compose run inventree-dev-server invoke test
|
|
||||||
docker-compose down
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
- name: Login to Dockerhub
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
- name: Build and Push
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: ./docker
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
|
||||||
push: true
|
|
||||||
target: production
|
|
||||||
tags: inventree/inventree:latest
|
|
||||||
- name: Image Digest
|
|
||||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
|
42
.github/workflows/docker_stable.yaml
vendored
42
.github/workflows/docker_stable.yaml
vendored
@ -1,42 +0,0 @@
|
|||||||
# Build and push docker image on push to 'stable' branch
|
|
||||||
# Docker build will be uploaded to dockerhub with the 'inventree:stable' tag
|
|
||||||
|
|
||||||
name: Docker Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'stable'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Check version number
|
|
||||||
run: |
|
|
||||||
python3 ci/check_version_number.py --release
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
- name: Login to Dockerhub
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
- name: Build and Push
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: ./docker
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
|
||||||
push: true
|
|
||||||
target: production
|
|
||||||
build-args:
|
|
||||||
branch=stable
|
|
||||||
tags: inventree/inventree:stable
|
|
||||||
- name: Image Digest
|
|
||||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
|
38
.github/workflows/docker_tag.yaml
vendored
38
.github/workflows/docker_tag.yaml
vendored
@ -1,38 +0,0 @@
|
|||||||
# Publish docker images to dockerhub on a tagged release
|
|
||||||
# Docker build will be uploaded to dockerhub with the 'invetree:<tag>' tag
|
|
||||||
|
|
||||||
name: Docker Publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish_image:
|
|
||||||
name: Push InvenTree web server image to dockerhub
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out repo
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Check Release tag
|
|
||||||
run: |
|
|
||||||
python3 ci/check_version_number.py --release --tag ${{ github.event.release.tag_name }}
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
- name: Login to Dockerhub
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
- name: Build and Push
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: ./docker
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
|
||||||
push: true
|
|
||||||
target: production
|
|
||||||
build-args:
|
|
||||||
tag=${{ github.event.release.tag_name }}
|
|
||||||
tags: inventree/inventree:${{ github.event.release.tag_name }}
|
|
27
.github/workflows/qc_checks.yaml
vendored
27
.github/workflows/qc_checks.yaml
vendored
@ -91,6 +91,9 @@ jobs:
|
|||||||
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@v2.0.3
|
||||||
|
- name: Check version number
|
||||||
|
run: |
|
||||||
|
python3 ci/check_version_number.py
|
||||||
|
|
||||||
python:
|
python:
|
||||||
name: Tests - inventree-python
|
name: Tests - inventree-python
|
||||||
@ -114,7 +117,7 @@ jobs:
|
|||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
apt-dependency: gettext
|
apt-dependency: gettext poppler-utils
|
||||||
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 }}
|
||||||
@ -130,22 +133,6 @@ jobs:
|
|||||||
invoke check-server
|
invoke check-server
|
||||||
coverage run -m unittest discover -s test/
|
coverage run -m unittest discover -s test/
|
||||||
|
|
||||||
docstyle:
|
|
||||||
name: Style [Python Docstrings]
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
needs: pre-commit
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v1
|
|
||||||
- name: Enviroment Setup
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
install: true
|
|
||||||
- name: Run flake8
|
|
||||||
run: flake8 InvenTree
|
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
name: Tests - DB [SQLite] + Coverage
|
name: Tests - DB [SQLite] + Coverage
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
@ -163,7 +150,7 @@ jobs:
|
|||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
apt-dependency: gettext
|
apt-dependency: gettext poppler-utils
|
||||||
update: true
|
update: true
|
||||||
- name: Coverage Tests
|
- name: Coverage Tests
|
||||||
run: invoke coverage
|
run: invoke coverage
|
||||||
@ -212,7 +199,7 @@ jobs:
|
|||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
apt-dependency: gettext libpq-dev
|
apt-dependency: gettext poppler-utils libpq-dev
|
||||||
pip-dependency: psycopg2 django-redis>=5.0.0
|
pip-dependency: psycopg2 django-redis>=5.0.0
|
||||||
update: true
|
update: true
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
@ -255,7 +242,7 @@ jobs:
|
|||||||
- name: Enviroment Setup
|
- name: Enviroment Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
apt-dependency: gettext libmysqlclient-dev
|
apt-dependency: gettext poppler-utils libmysqlclient-dev
|
||||||
pip-dependency: mysqlclient
|
pip-dependency: mysqlclient
|
||||||
update: true
|
update: true
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
|
21
.github/workflows/version.yml
vendored
21
.github/workflows/version.yml
vendored
@ -1,21 +0,0 @@
|
|||||||
# Checks version number
|
|
||||||
name: version number
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches-ignore:
|
|
||||||
- l10*
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
check_version:
|
|
||||||
name: version number
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Check version number
|
|
||||||
run: |
|
|
||||||
python3 ci/check_version_number.py --branch ${{ github.base_ref }}
|
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -8,6 +8,7 @@ __pycache__/
|
|||||||
env/
|
env/
|
||||||
inventree-env/
|
inventree-env/
|
||||||
./build/
|
./build/
|
||||||
|
.cache/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
bin/
|
bin/
|
||||||
@ -26,7 +27,6 @@ var/
|
|||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
|
|
||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
@ -38,6 +38,8 @@ local_settings.py
|
|||||||
# Files used for testing
|
# Files used for testing
|
||||||
dummy_image.*
|
dummy_image.*
|
||||||
_tmp.csv
|
_tmp.csv
|
||||||
|
inventree/label.pdf
|
||||||
|
inventree/label.png
|
||||||
|
|
||||||
# Sphinx files
|
# Sphinx files
|
||||||
docs/_build
|
docs/_build
|
||||||
@ -63,6 +65,7 @@ secret_key.txt
|
|||||||
.idea/
|
.idea/
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.bash_history
|
||||||
|
|
||||||
# Coverage reports
|
# Coverage reports
|
||||||
.coverage
|
.coverage
|
||||||
|
@ -1,37 +1,39 @@
|
|||||||
FROM alpine:3.14 as base
|
# The InvenTree dockerfile provides two build targets:
|
||||||
|
#
|
||||||
|
# production:
|
||||||
|
# - Required files are copied into the image
|
||||||
|
# - Runs InvenTree web server under gunicorn
|
||||||
|
#
|
||||||
|
# dev:
|
||||||
|
# - Expects source directories to be loaded as a run-time volume
|
||||||
|
# - Runs InvenTree web server under django development server
|
||||||
|
# - Monitors source files for any changes, and live-reloads server
|
||||||
|
|
||||||
# GitHub source
|
|
||||||
ARG repository="https://github.com/inventree/InvenTree.git"
|
|
||||||
ARG branch="master"
|
|
||||||
|
|
||||||
# Optionally specify a particular tag to checkout
|
FROM python:3.9-slim as base
|
||||||
ARG tag=""
|
|
||||||
|
# Build arguments for this image
|
||||||
|
ARG commit_hash=""
|
||||||
|
ARG commit_date=""
|
||||||
|
ARG commit_tag=""
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED 1
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
|
||||||
# Ref: https://github.com/pyca/cryptography/issues/5776
|
# Ref: https://github.com/pyca/cryptography/issues/5776
|
||||||
ENV CRYPTOGRAPHY_DONT_BUILD_RUST 1
|
ENV CRYPTOGRAPHY_DONT_BUILD_RUST 1
|
||||||
|
|
||||||
# InvenTree key settings
|
|
||||||
|
|
||||||
# The INVENTREE_HOME directory is where the InvenTree source repository will be located
|
|
||||||
ENV INVENTREE_HOME="/home/inventree"
|
|
||||||
|
|
||||||
# GitHub settings
|
|
||||||
ENV INVENTREE_GIT_REPO="${repository}"
|
|
||||||
ENV INVENTREE_GIT_BRANCH="${branch}"
|
|
||||||
ENV INVENTREE_GIT_TAG="${tag}"
|
|
||||||
|
|
||||||
ENV INVENTREE_LOG_LEVEL="INFO"
|
ENV INVENTREE_LOG_LEVEL="INFO"
|
||||||
ENV INVENTREE_DOCKER="true"
|
ENV INVENTREE_DOCKER="true"
|
||||||
|
|
||||||
# InvenTree paths
|
# InvenTree paths
|
||||||
|
ENV INVENTREE_HOME="/home/inventree"
|
||||||
ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
|
ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
|
||||||
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
|
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
|
||||||
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
||||||
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
||||||
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
|
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
|
||||||
|
|
||||||
|
# InvenTree configuration files
|
||||||
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
|
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
|
||||||
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
|
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
|
||||||
ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DATA_DIR}/plugins.txt"
|
ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DATA_DIR}/plugins.txt"
|
||||||
@ -49,82 +51,82 @@ LABEL org.label-schema.schema-version="1.0" \
|
|||||||
org.label-schema.vendor="inventree" \
|
org.label-schema.vendor="inventree" \
|
||||||
org.label-schema.name="inventree/inventree" \
|
org.label-schema.name="inventree/inventree" \
|
||||||
org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \
|
org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \
|
||||||
org.label-schema.vcs-url=${INVENTREE_GIT_REPO} \
|
org.label-schema.vcs-url="https://github.com/inventree/InvenTree.git" \
|
||||||
org.label-schema.vcs-branch=${INVENTREE_GIT_BRANCH} \
|
org.label-schema.vcs-ref=${commit_tag}
|
||||||
org.label-schema.vcs-ref=${INVENTREE_GIT_TAG}
|
|
||||||
|
|
||||||
# Create user account
|
# RUN apt-get upgrade && apt-get update
|
||||||
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
|
RUN apt-get update
|
||||||
|
|
||||||
RUN apk -U upgrade
|
|
||||||
|
|
||||||
# Install required system packages
|
# Install required system packages
|
||||||
RUN apk add --no-cache git make bash \
|
RUN apt-get install -y --no-install-recommends \
|
||||||
gcc libgcc g++ libstdc++ \
|
git gcc g++ gettext gnupg libffi-dev \
|
||||||
gnupg \
|
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
|
||||||
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev libwebp-dev \
|
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
|
||||||
libffi libffi-dev \
|
# Image format support
|
||||||
zlib zlib-dev \
|
libjpeg-dev webp \
|
||||||
# Special deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
|
|
||||||
cairo cairo-dev pango pango-dev gdk-pixbuf \
|
|
||||||
# Fonts
|
|
||||||
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans font-croscore font-noto \
|
|
||||||
# Core python
|
|
||||||
python3 python3-dev py3-pip \
|
|
||||||
# SQLite support
|
# SQLite support
|
||||||
sqlite \
|
sqlite3 \
|
||||||
# PostgreSQL support
|
# PostgreSQL support
|
||||||
postgresql postgresql-contrib postgresql-dev libpq \
|
libpq-dev \
|
||||||
# MySQL/MariaDB support
|
# MySQL / MariaDB support
|
||||||
mariadb-connector-c mariadb-dev mariadb-client \
|
default-libmysqlclient-dev mariadb-client && \
|
||||||
# Required for python cryptography support
|
apt-get autoclean && apt-get autoremove
|
||||||
openssl-dev musl-dev libffi-dev rust cargo
|
|
||||||
|
|
||||||
# Update pip
|
# Update pip
|
||||||
RUN pip install --upgrade pip
|
RUN pip install --upgrade pip
|
||||||
|
|
||||||
# Install required base-level python packages
|
# Install required base-level python packages
|
||||||
COPY requirements.txt requirements.txt
|
COPY ./docker/requirements.txt base_requirements.txt
|
||||||
RUN pip install --no-cache-dir -U -r requirements.txt
|
RUN pip install --disable-pip-version-check -U -r base_requirements.txt
|
||||||
|
|
||||||
|
# InvenTree production image:
|
||||||
|
# - Copies required files from local directory
|
||||||
|
# - Installs required python packages from requirements.txt
|
||||||
|
# - Starts a gunicorn webserver
|
||||||
|
|
||||||
# Production code (pulled from tagged github release)
|
|
||||||
FROM base as production
|
FROM base as production
|
||||||
|
|
||||||
# Clone source code
|
ENV INVENTREE_DEBUG=False
|
||||||
RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}"
|
|
||||||
|
|
||||||
RUN git clone --branch ${INVENTREE_GIT_BRANCH} --depth 1 ${INVENTREE_GIT_REPO} ${INVENTREE_HOME}
|
# As .git directory is not available in production image, we pass the commit information via ENV
|
||||||
|
ENV INVENTREE_COMMIT_HASH="${commit_hash}"
|
||||||
|
ENV INVENTREE_COMMIT_DATE="${commit_date}"
|
||||||
|
|
||||||
# Ref: https://github.blog/2022-04-12-git-security-vulnerability-announced/
|
# Copy source code
|
||||||
RUN git config --global --add safe.directory ${INVENTREE_HOME}
|
COPY InvenTree ${INVENTREE_HOME}/InvenTree
|
||||||
|
|
||||||
# Checkout against a particular git tag
|
# Copy other key files
|
||||||
RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch --all --tags && git checkout tags/${INVENTREE_GIT_TAG} -b v${INVENTREE_GIT_TAG}-branch ; fi
|
COPY requirements.txt ${INVENTREE_HOME}/requirements.txt
|
||||||
|
COPY tasks.py ${INVENTREE_HOME}/tasks.py
|
||||||
RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/*
|
COPY docker/gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
|
||||||
|
COPY docker/init.sh ${INVENTREE_MNG_DIR}/init.sh
|
||||||
# Drop to the inventree user
|
|
||||||
USER inventree
|
|
||||||
|
|
||||||
# Install InvenTree packages
|
|
||||||
RUN pip3 install --user --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
|
|
||||||
|
|
||||||
# Need to be running from within this directory
|
# Need to be running from within this directory
|
||||||
WORKDIR ${INVENTREE_MNG_DIR}
|
WORKDIR ${INVENTREE_MNG_DIR}
|
||||||
|
|
||||||
|
# Drop to the inventree user for the production image
|
||||||
|
#RUN adduser inventree
|
||||||
|
#RUN chown -R inventree:inventree ${INVENTREE_HOME}
|
||||||
|
#USER inventree
|
||||||
|
|
||||||
|
# Install InvenTree packages
|
||||||
|
RUN pip3 install --user --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
|
||||||
|
|
||||||
# Server init entrypoint
|
# Server init entrypoint
|
||||||
ENTRYPOINT ["/bin/bash", "../docker/init.sh"]
|
ENTRYPOINT ["/bin/bash", "./init.sh"]
|
||||||
|
|
||||||
# Launch the production server
|
# Launch the production server
|
||||||
# TODO: Work out why environment variables cannot be interpolated in this command
|
# TODO: Work out why environment variables cannot be interpolated in this command
|
||||||
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
|
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
|
||||||
CMD gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
|
CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
|
||||||
|
|
||||||
FROM base as dev
|
FROM base as dev
|
||||||
|
|
||||||
# The development image requires the source code to be mounted to /home/inventree/
|
# The development image requires the source code to be mounted to /home/inventree/
|
||||||
# So from here, we don't actually "do" anything, apart from some file management
|
# So from here, we don't actually "do" anything, apart from some file management
|
||||||
|
|
||||||
|
ENV INVENTREE_DEBUG=True
|
||||||
|
|
||||||
ENV INVENTREE_DEV_DIR="${INVENTREE_HOME}/dev"
|
ENV INVENTREE_DEV_DIR="${INVENTREE_HOME}/dev"
|
||||||
|
|
||||||
# Location for python virtual environment
|
# Location for python virtual environment
|
@ -106,6 +106,11 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
response = self.client.get(url, data, format='json')
|
response = self.client.get(url, data, format='json')
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
|
|
||||||
|
if response.status_code != expected_code:
|
||||||
|
print(f"Unexpected response at '{url}':")
|
||||||
|
print(response.data)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
@ -36,7 +36,11 @@ def exception_handler(exc, context):
|
|||||||
if response is None:
|
if response is None:
|
||||||
# DRF handler did not provide a default response for this exception
|
# DRF handler did not provide a default response for this exception
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.TESTING:
|
||||||
|
# If in TESTING mode, re-throw the exception for traceback
|
||||||
|
raise exc
|
||||||
|
elif settings.DEBUG:
|
||||||
|
# If in DEBUG mode, provide error information in the response
|
||||||
error_detail = str(exc)
|
error_detail = str(exc)
|
||||||
else:
|
else:
|
||||||
error_detail = _("Error details can be found in the admin panel")
|
error_detail = _("Error details can be found in the admin panel")
|
||||||
|
@ -105,7 +105,7 @@ def TestIfImageURL(url):
|
|||||||
Simply tests the extension against a set of allowed values
|
Simply tests the extension against a set of allowed values
|
||||||
"""
|
"""
|
||||||
return os.path.splitext(os.path.basename(url))[-1].lower() in [
|
return os.path.splitext(os.path.basename(url))[-1].lower() in [
|
||||||
'.jpg', '.jpeg',
|
'.jpg', '.jpeg', '.j2k',
|
||||||
'.png', '.bmp',
|
'.png', '.bmp',
|
||||||
'.tif', '.tiff',
|
'.tif', '.tiff',
|
||||||
'.webp', '.gif',
|
'.webp', '.gif',
|
||||||
|
@ -583,4 +583,4 @@ class DataFileExtractSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""No "save" action for this serializer."""
|
"""No "save" action for this serializer."""
|
||||||
...
|
pass
|
||||||
|
@ -366,6 +366,30 @@ class TestVersionNumber(TestCase):
|
|||||||
self.assertTrue(v_d > v_c)
|
self.assertTrue(v_d > v_c)
|
||||||
self.assertTrue(v_d > v_a)
|
self.assertTrue(v_d > v_a)
|
||||||
|
|
||||||
|
def test_commit_info(self):
|
||||||
|
"""Test that the git commit information is extracted successfully"""
|
||||||
|
|
||||||
|
envs = {
|
||||||
|
'INVENTREE_COMMIT_HASH': 'abcdef',
|
||||||
|
'INVENTREE_COMMIT_DATE': '2022-12-31'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check that the environment variables take priority
|
||||||
|
|
||||||
|
with mock.patch.dict(os.environ, envs):
|
||||||
|
self.assertEqual(version.inventreeCommitHash(), 'abcdef')
|
||||||
|
self.assertEqual(version.inventreeCommitDate(), '2022-12-31')
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Check that the current .git values work too
|
||||||
|
|
||||||
|
hash = str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
||||||
|
self.assertEqual(hash, version.inventreeCommitHash())
|
||||||
|
|
||||||
|
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip().split(' ')[0]
|
||||||
|
self.assertEqual(d, version.inventreeCommitDate())
|
||||||
|
|
||||||
|
|
||||||
class CurrencyTests(TestCase):
|
class CurrencyTests(TestCase):
|
||||||
"""Unit tests for currency / exchange rate functionality."""
|
"""Unit tests for currency / exchange rate functionality."""
|
||||||
@ -502,7 +526,7 @@ class TestSettings(helpers.InvenTreeTestCase):
|
|||||||
|
|
||||||
# Set dynamic setting to True and rerun to launch install
|
# Set dynamic setting to True and rerun to launch install
|
||||||
InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user)
|
InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user)
|
||||||
registry.reload_plugins()
|
registry.reload_plugins(full_reload=True)
|
||||||
|
|
||||||
# Check that there was anotehr run
|
# Check that there was anotehr run
|
||||||
response = registry.install_plugin_file()
|
response = registry.install_plugin_file()
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
"""Version information for InvenTree.
|
"""
|
||||||
|
Version information for InvenTree.
|
||||||
Provides information on the current InvenTree version
|
Provides information on the current InvenTree version
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@ -16,12 +17,12 @@ INVENTREE_SW_VERSION = "0.8.0 dev"
|
|||||||
|
|
||||||
|
|
||||||
def inventreeInstanceName():
|
def inventreeInstanceName():
|
||||||
"""Returns the InstanceName settings for the current database."""
|
""" Returns the InstanceName settings for the current database """
|
||||||
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
||||||
|
|
||||||
|
|
||||||
def inventreeInstanceTitle():
|
def inventreeInstanceTitle():
|
||||||
"""Returns the InstanceTitle for the current database."""
|
""" Returns the InstanceTitle for the current database """
|
||||||
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
|
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
|
||||||
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
||||||
else:
|
else:
|
||||||
@ -29,12 +30,13 @@ def inventreeInstanceTitle():
|
|||||||
|
|
||||||
|
|
||||||
def inventreeVersion():
|
def inventreeVersion():
|
||||||
"""Returns the InvenTree version string."""
|
""" Returns the InvenTree version string """
|
||||||
return INVENTREE_SW_VERSION.lower().strip()
|
return INVENTREE_SW_VERSION.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
def inventreeVersionTuple(version=None):
|
def inventreeVersionTuple(version=None):
|
||||||
"""Return the InvenTree version string as (maj, min, sub) tuple."""
|
""" Return the InvenTree version string as (maj, min, sub) tuple """
|
||||||
|
|
||||||
if version is None:
|
if version is None:
|
||||||
version = INVENTREE_SW_VERSION
|
version = INVENTREE_SW_VERSION
|
||||||
|
|
||||||
@ -44,16 +46,21 @@ def inventreeVersionTuple(version=None):
|
|||||||
|
|
||||||
|
|
||||||
def isInvenTreeDevelopmentVersion():
|
def isInvenTreeDevelopmentVersion():
|
||||||
"""Return True if current InvenTree version is a "development" version."""
|
"""
|
||||||
|
Return True if current InvenTree version is a "development" version
|
||||||
|
"""
|
||||||
return inventreeVersion().endswith('dev')
|
return inventreeVersion().endswith('dev')
|
||||||
|
|
||||||
|
|
||||||
def inventreeDocsVersion():
|
def inventreeDocsVersion():
|
||||||
"""Return the version string matching the latest documentation.
|
"""
|
||||||
|
Return the version string matching the latest documentation.
|
||||||
|
|
||||||
Development -> "latest"
|
Development -> "latest"
|
||||||
Release -> "major.minor.sub" e.g. "0.5.2"
|
Release -> "major.minor.sub" e.g. "0.5.2"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if isInvenTreeDevelopmentVersion():
|
if isInvenTreeDevelopmentVersion():
|
||||||
return "latest"
|
return "latest"
|
||||||
else:
|
else:
|
||||||
@ -61,10 +68,13 @@ def inventreeDocsVersion():
|
|||||||
|
|
||||||
|
|
||||||
def isInvenTreeUpToDate():
|
def isInvenTreeUpToDate():
|
||||||
"""Test if the InvenTree instance is "up to date" with the latest version.
|
|
||||||
|
|
||||||
A background task periodically queries GitHub for latest version, and stores it to the database as INVENTREE_LATEST_VERSION
|
|
||||||
"""
|
"""
|
||||||
|
Test if the InvenTree instance is "up to date" with the latest version.
|
||||||
|
|
||||||
|
A background task periodically queries GitHub for latest version,
|
||||||
|
and stores it to the database as INVENTREE_LATEST_VERSION
|
||||||
|
"""
|
||||||
|
|
||||||
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
||||||
|
|
||||||
# No record for "latest" version - we must assume we are up to date!
|
# No record for "latest" version - we must assume we are up to date!
|
||||||
@ -83,12 +93,19 @@ def inventreeApiVersion():
|
|||||||
|
|
||||||
|
|
||||||
def inventreeDjangoVersion():
|
def inventreeDjangoVersion():
|
||||||
"""Return the version of Django library."""
|
""" Return the version of Django library """
|
||||||
return django.get_version()
|
return django.get_version()
|
||||||
|
|
||||||
|
|
||||||
def inventreeCommitHash():
|
def inventreeCommitHash():
|
||||||
"""Returns the git commit hash for the running codebase."""
|
""" Returns the git commit hash for the running codebase """
|
||||||
|
|
||||||
|
# First look in the environment variables, i.e. if running in docker
|
||||||
|
commit_hash = os.environ.get('INVENTREE_COMMIT_HASH', '')
|
||||||
|
|
||||||
|
if commit_hash:
|
||||||
|
return commit_hash
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
||||||
except: # pragma: no cover
|
except: # pragma: no cover
|
||||||
@ -96,7 +113,14 @@ def inventreeCommitHash():
|
|||||||
|
|
||||||
|
|
||||||
def inventreeCommitDate():
|
def inventreeCommitDate():
|
||||||
"""Returns the git commit date for the running codebase."""
|
""" Returns the git commit date for the running codebase """
|
||||||
|
|
||||||
|
# First look in the environment variables, e.g. if running in docker
|
||||||
|
commit_date = os.environ.get('INVENTREE_COMMIT_DATE', '')
|
||||||
|
|
||||||
|
if commit_date:
|
||||||
|
return commit_date.split(' ')[0]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
||||||
return d.split(' ')[0]
|
return d.split(' ')[0]
|
||||||
|
@ -12,7 +12,9 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
# region methods
|
# region methods
|
||||||
class NotificationMethod:
|
class NotificationMethod:
|
||||||
"""Base class for notification methods."""
|
"""
|
||||||
|
Base class for notification methods
|
||||||
|
"""
|
||||||
|
|
||||||
METHOD_NAME = ''
|
METHOD_NAME = ''
|
||||||
METHOD_ICON = None
|
METHOD_ICON = None
|
||||||
@ -90,11 +92,11 @@ class NotificationMethod:
|
|||||||
|
|
||||||
# region plugins
|
# region plugins
|
||||||
def get_plugin(self):
|
def get_plugin(self):
|
||||||
"""Returns plugin class."""
|
"""Returns plugin class"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def global_setting_disable(self):
|
def global_setting_disable(self):
|
||||||
"""Check if the method is defined in a plugin and has a global setting."""
|
"""Check if the method is defined in a plugin and has a global setting"""
|
||||||
# Check if plugin has a setting
|
# Check if plugin has a setting
|
||||||
if not self.GLOBAL_SETTING:
|
if not self.GLOBAL_SETTING:
|
||||||
return False
|
return False
|
||||||
@ -113,7 +115,9 @@ class NotificationMethod:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def usersetting(self, target):
|
def usersetting(self, target):
|
||||||
"""Returns setting for this method for a given user."""
|
"""
|
||||||
|
Returns setting for this method for a given user
|
||||||
|
"""
|
||||||
return NotificationUserSetting.get_setting(f'NOTIFICATION_METHOD_{self.METHOD_NAME.upper()}', user=target, method=self.METHOD_NAME)
|
return NotificationUserSetting.get_setting(f'NOTIFICATION_METHOD_{self.METHOD_NAME.upper()}', user=target, method=self.METHOD_NAME)
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
@ -199,8 +203,11 @@ class UIMessageNotification(SingleNotificationMethod):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs):
|
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||||
"""Send out a notification."""
|
"""
|
||||||
|
Send out a notification
|
||||||
|
"""
|
||||||
|
|
||||||
targets = kwargs.get('targets', None)
|
targets = kwargs.get('targets', None)
|
||||||
target_fnc = kwargs.get('target_fnc', None)
|
target_fnc = kwargs.get('target_fnc', None)
|
||||||
target_args = kwargs.get('target_args', [])
|
target_args = kwargs.get('target_args', [])
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import FieldError, ValidationError
|
from django.core.exceptions import FieldError, ValidationError
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from PIL import Image
|
|
||||||
from rest_framework import filters, generics
|
from rest_framework import filters, generics
|
||||||
from rest_framework.exceptions import NotFound
|
from rest_framework.exceptions import NotFound
|
||||||
|
|
||||||
@ -24,7 +21,9 @@ from .serializers import (PartLabelSerializer, StockItemLabelSerializer,
|
|||||||
|
|
||||||
|
|
||||||
class LabelListView(generics.ListAPIView):
|
class LabelListView(generics.ListAPIView):
|
||||||
"""Generic API class for label templates."""
|
"""
|
||||||
|
Generic API class for label templates
|
||||||
|
"""
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
@ -42,10 +41,14 @@ class LabelListView(generics.ListAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class LabelPrintMixin:
|
class LabelPrintMixin:
|
||||||
"""Mixin for printing labels."""
|
"""
|
||||||
|
Mixin for printing labels
|
||||||
|
"""
|
||||||
|
|
||||||
def get_plugin(self, request):
|
def get_plugin(self, request):
|
||||||
"""Return the label printing plugin associated with this request. This is provided in the url, e.g. ?plugin=myprinter.
|
"""
|
||||||
|
Return the label printing plugin associated with this request.
|
||||||
|
This is provided in the url, e.g. ?plugin=myprinter
|
||||||
|
|
||||||
Requires:
|
Requires:
|
||||||
- settings.PLUGINS_ENABLED is True
|
- settings.PLUGINS_ENABLED is True
|
||||||
@ -53,6 +56,7 @@ class LabelPrintMixin:
|
|||||||
- matching plugin implements the 'labels' mixin
|
- matching plugin implements the 'labels' mixin
|
||||||
- matching plugin is enabled
|
- matching plugin is enabled
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not settings.PLUGINS_ENABLED:
|
if not settings.PLUGINS_ENABLED:
|
||||||
return None # pragma: no cover
|
return None # pragma: no cover
|
||||||
|
|
||||||
@ -76,7 +80,10 @@ class LabelPrintMixin:
|
|||||||
raise NotFound(f"Plugin '{plugin_key}' not found")
|
raise NotFound(f"Plugin '{plugin_key}' not found")
|
||||||
|
|
||||||
def print(self, request, items_to_print):
|
def print(self, request, items_to_print):
|
||||||
"""Print this label template against a number of pre-validated items."""
|
"""
|
||||||
|
Print this label template against a number of pre-validated items
|
||||||
|
"""
|
||||||
|
|
||||||
# Check the request to determine if the user has selected a label printing plugin
|
# Check the request to determine if the user has selected a label printing plugin
|
||||||
plugin = self.get_plugin(request)
|
plugin = self.get_plugin(request)
|
||||||
|
|
||||||
@ -113,34 +120,35 @@ class LabelPrintMixin:
|
|||||||
|
|
||||||
if plugin is not None:
|
if plugin is not None:
|
||||||
"""
|
"""
|
||||||
Label printing is to be handled by a plugin, rather than being exported to PDF.
|
Label printing is to be handled by a plugin,
|
||||||
|
rather than being exported to PDF.
|
||||||
|
|
||||||
In this case, we do the following:
|
In this case, we do the following:
|
||||||
|
|
||||||
- Individually generate each label, exporting as an image file
|
- Individually generate each label, exporting as an image file
|
||||||
- Pass all the images through to the label printing plugin
|
- Pass all the images through to the label printing plugin
|
||||||
- Return a JSON response indicating that the printing has been offloaded
|
- Return a JSON response indicating that the printing has been offloaded
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Label instance
|
# Label instance
|
||||||
label_instance = self.get_object()
|
label_instance = self.get_object()
|
||||||
|
|
||||||
for output in outputs:
|
for idx, output in enumerate(outputs):
|
||||||
"""For each output, we generate a temporary image file, which will then get sent to the printer."""
|
"""
|
||||||
|
For each output, we generate a temporary image file,
|
||||||
|
which will then get sent to the printer
|
||||||
|
"""
|
||||||
|
|
||||||
# Generate a png image at 300dpi
|
# Generate PDF data for the label
|
||||||
(img_data, w, h) = output.get_document().write_png(resolution=300)
|
pdf = output.get_document().write_pdf()
|
||||||
|
|
||||||
# Construct a BytesIO object, which can be read by pillow
|
|
||||||
img_bytes = BytesIO(img_data)
|
|
||||||
|
|
||||||
image = Image.open(img_bytes)
|
|
||||||
|
|
||||||
# Offload a background task to print the provided label
|
# Offload a background task to print the provided label
|
||||||
offload_task(
|
offload_task(
|
||||||
plugin_label.print_label,
|
plugin_label.print_label,
|
||||||
plugin.plugin_slug(),
|
plugin.plugin_slug(),
|
||||||
image,
|
pdf,
|
||||||
|
filename=label_names[idx],
|
||||||
label_instance=label_instance,
|
label_instance=label_instance,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
)
|
)
|
||||||
@ -151,14 +159,20 @@ class LabelPrintMixin:
|
|||||||
})
|
})
|
||||||
|
|
||||||
elif debug_mode:
|
elif debug_mode:
|
||||||
"""Contatenate all rendered templates into a single HTML string, and return the string as a HTML response."""
|
"""
|
||||||
|
Contatenate all rendered templates into a single HTML string,
|
||||||
|
and return the string as a HTML response.
|
||||||
|
"""
|
||||||
|
|
||||||
html = "\n".join(outputs)
|
html = "\n".join(outputs)
|
||||||
|
|
||||||
return HttpResponse(html)
|
return HttpResponse(html)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
"""Concatenate all rendered pages into a single PDF object, and return the resulting document!"""
|
"""
|
||||||
|
Concatenate all rendered pages into a single PDF object,
|
||||||
|
and return the resulting document!
|
||||||
|
"""
|
||||||
|
|
||||||
pages = []
|
pages = []
|
||||||
|
|
||||||
@ -184,10 +198,15 @@ class LabelPrintMixin:
|
|||||||
|
|
||||||
|
|
||||||
class StockItemLabelMixin:
|
class StockItemLabelMixin:
|
||||||
"""Mixin for extracting stock items from query params."""
|
"""
|
||||||
|
Mixin for extracting stock items from query params
|
||||||
|
"""
|
||||||
|
|
||||||
def get_items(self):
|
def get_items(self):
|
||||||
"""Return a list of requested stock items."""
|
"""
|
||||||
|
Return a list of requested stock items
|
||||||
|
"""
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -212,20 +231,25 @@ class StockItemLabelMixin:
|
|||||||
|
|
||||||
|
|
||||||
class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
||||||
"""API endpoint for viewing list of StockItemLabel objects.
|
"""
|
||||||
|
API endpoint for viewing list of StockItemLabel objects.
|
||||||
|
|
||||||
Filterable by:
|
Filterable by:
|
||||||
|
|
||||||
- enabled: Filter by enabled / disabled status
|
- enabled: Filter by enabled / disabled status
|
||||||
- item: Filter by single stock item
|
- item: Filter by single stock item
|
||||||
- items: Filter by list of stock items
|
- items: Filter by list of stock items
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = StockItemLabel.objects.all()
|
queryset = StockItemLabel.objects.all()
|
||||||
serializer_class = StockItemLabelSerializer
|
serializer_class = StockItemLabelSerializer
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""Filter the StockItem label queryset."""
|
"""
|
||||||
|
Filter the StockItem label queryset.
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
# List of StockItem objects to match against
|
# List of StockItem objects to match against
|
||||||
@ -234,7 +258,9 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
|||||||
# We wish to filter by stock items
|
# We wish to filter by stock items
|
||||||
if len(items) > 0:
|
if len(items) > 0:
|
||||||
"""
|
"""
|
||||||
At this point, we are basically forced to be inefficient, as we need to compare the 'filters' string of each label, and see if it matches against each of the requested items.
|
At this point, we are basically forced to be inefficient,
|
||||||
|
as we need to compare the 'filters' string of each label,
|
||||||
|
and see if it matches against each of the requested items.
|
||||||
|
|
||||||
TODO: In the future, if this becomes excessively slow, it
|
TODO: In the future, if this becomes excessively slow, it
|
||||||
will need to be readdressed.
|
will need to be readdressed.
|
||||||
@ -278,30 +304,42 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
|||||||
|
|
||||||
|
|
||||||
class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""API endpoint for a single StockItemLabel object."""
|
"""
|
||||||
|
API endpoint for a single StockItemLabel object
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = StockItemLabel.objects.all()
|
queryset = StockItemLabel.objects.all()
|
||||||
serializer_class = StockItemLabelSerializer
|
serializer_class = StockItemLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin):
|
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin):
|
||||||
"""API endpoint for printing a StockItemLabel object."""
|
"""
|
||||||
|
API endpoint for printing a StockItemLabel object
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = StockItemLabel.objects.all()
|
queryset = StockItemLabel.objects.all()
|
||||||
serializer_class = StockItemLabelSerializer
|
serializer_class = StockItemLabelSerializer
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Check if valid stock item(s) have been provided."""
|
"""
|
||||||
|
Check if valid stock item(s) have been provided.
|
||||||
|
"""
|
||||||
|
|
||||||
items = self.get_items()
|
items = self.get_items()
|
||||||
|
|
||||||
return self.print(request, items)
|
return self.print(request, items)
|
||||||
|
|
||||||
|
|
||||||
class StockLocationLabelMixin:
|
class StockLocationLabelMixin:
|
||||||
"""Mixin for extracting stock locations from query params."""
|
"""
|
||||||
|
Mixin for extracting stock locations from query params
|
||||||
|
"""
|
||||||
|
|
||||||
def get_locations(self):
|
def get_locations(self):
|
||||||
"""Return a list of requested stock locations."""
|
"""
|
||||||
|
Return a list of requested stock locations
|
||||||
|
"""
|
||||||
|
|
||||||
locations = []
|
locations = []
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -326,7 +364,8 @@ class StockLocationLabelMixin:
|
|||||||
|
|
||||||
|
|
||||||
class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
||||||
"""API endpoint for viewiing list of StockLocationLabel objects.
|
"""
|
||||||
|
API endpoint for viewiing list of StockLocationLabel objects.
|
||||||
|
|
||||||
Filterable by:
|
Filterable by:
|
||||||
|
|
||||||
@ -339,7 +378,10 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
|||||||
serializer_class = StockLocationLabelSerializer
|
serializer_class = StockLocationLabelSerializer
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""Filter the StockLocationLabel queryset."""
|
"""
|
||||||
|
Filter the StockLocationLabel queryset
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
# List of StockLocation objects to match against
|
# List of StockLocation objects to match against
|
||||||
@ -348,7 +390,9 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
|||||||
# We wish to filter by stock location(s)
|
# We wish to filter by stock location(s)
|
||||||
if len(locations) > 0:
|
if len(locations) > 0:
|
||||||
"""
|
"""
|
||||||
At this point, we are basically forced to be inefficient, as we need to compare the 'filters' string of each label, and see if it matches against each of the requested items.
|
At this point, we are basically forced to be inefficient,
|
||||||
|
as we need to compare the 'filters' string of each label,
|
||||||
|
and see if it matches against each of the requested items.
|
||||||
|
|
||||||
TODO: In the future, if this becomes excessively slow, it
|
TODO: In the future, if this becomes excessively slow, it
|
||||||
will need to be readdressed.
|
will need to be readdressed.
|
||||||
@ -392,14 +436,18 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
|||||||
|
|
||||||
|
|
||||||
class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""API endpoint for a single StockLocationLabel object."""
|
"""
|
||||||
|
API endpoint for a single StockLocationLabel object
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = StockLocationLabel.objects.all()
|
queryset = StockLocationLabel.objects.all()
|
||||||
serializer_class = StockLocationLabelSerializer
|
serializer_class = StockLocationLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin):
|
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin):
|
||||||
"""API endpoint for printing a StockLocationLabel object."""
|
"""
|
||||||
|
API endpoint for printing a StockLocationLabel object
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = StockLocationLabel.objects.all()
|
queryset = StockLocationLabel.objects.all()
|
||||||
seiralizer_class = StockLocationLabelSerializer
|
seiralizer_class = StockLocationLabelSerializer
|
||||||
@ -412,10 +460,15 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin,
|
|||||||
|
|
||||||
|
|
||||||
class PartLabelMixin:
|
class PartLabelMixin:
|
||||||
"""Mixin for extracting Part objects from query parameters."""
|
"""
|
||||||
|
Mixin for extracting Part objects from query parameters
|
||||||
|
"""
|
||||||
|
|
||||||
def get_parts(self):
|
def get_parts(self):
|
||||||
"""Return a list of requested Part objects."""
|
"""
|
||||||
|
Return a list of requested Part objects
|
||||||
|
"""
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -438,7 +491,9 @@ class PartLabelMixin:
|
|||||||
|
|
||||||
|
|
||||||
class PartLabelList(LabelListView, PartLabelMixin):
|
class PartLabelList(LabelListView, PartLabelMixin):
|
||||||
"""API endpoint for viewing list of PartLabel objects."""
|
"""
|
||||||
|
API endpoint for viewing list of PartLabel objects
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = PartLabel.objects.all()
|
queryset = PartLabel.objects.all()
|
||||||
serializer_class = PartLabelSerializer
|
serializer_class = PartLabelSerializer
|
||||||
@ -484,20 +539,27 @@ class PartLabelList(LabelListView, PartLabelMixin):
|
|||||||
|
|
||||||
|
|
||||||
class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""API endpoint for a single PartLabel object."""
|
"""
|
||||||
|
API endpoint for a single PartLabel object
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = PartLabel.objects.all()
|
queryset = PartLabel.objects.all()
|
||||||
serializer_class = PartLabelSerializer
|
serializer_class = PartLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin):
|
class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin):
|
||||||
"""API endpoint for printing a PartLabel object."""
|
"""
|
||||||
|
API endpoint for printing a PartLabel object
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = PartLabel.objects.all()
|
queryset = PartLabel.objects.all()
|
||||||
serializer_class = PartLabelSerializer
|
serializer_class = PartLabelSerializer
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Check if valid part(s) have been provided."""
|
"""
|
||||||
|
Check if valid part(s) have been provided
|
||||||
|
"""
|
||||||
|
|
||||||
parts = self.get_parts()
|
parts = self.get_parts()
|
||||||
|
|
||||||
return self.print(request, parts)
|
return self.print(request, parts)
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,8 +1 @@
|
|||||||
"""The Part module is responsible for Part management.
|
"""The Part module is responsible for Part management."""
|
||||||
|
|
||||||
It includes models for:
|
|
||||||
|
|
||||||
- PartCategory
|
|
||||||
- Part
|
|
||||||
- BomItem
|
|
||||||
"""
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
"""Admin class definitions for the 'part' app"""
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
import import_export.widgets as widgets
|
import import_export.widgets as widgets
|
||||||
@ -38,6 +40,7 @@ class PartResource(ModelResource):
|
|||||||
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
|
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass definition"""
|
||||||
model = models.Part
|
model = models.Part
|
||||||
skip_unchanged = True
|
skip_unchanged = True
|
||||||
report_skipped = False
|
report_skipped = False
|
||||||
@ -61,8 +64,17 @@ class PartResource(ModelResource):
|
|||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||||
|
"""Rebuild MPTT tree structure after importing Part data"""
|
||||||
|
|
||||||
|
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
|
||||||
|
|
||||||
|
# Rebuild the Part tree(s)
|
||||||
|
models.Part.objects.rebuild()
|
||||||
|
|
||||||
|
|
||||||
class PartAdmin(ImportExportModelAdmin):
|
class PartAdmin(ImportExportModelAdmin):
|
||||||
|
"""Admin class for the Part model"""
|
||||||
|
|
||||||
resource_class = PartResource
|
resource_class = PartResource
|
||||||
|
|
||||||
@ -90,6 +102,7 @@ class PartCategoryResource(ModelResource):
|
|||||||
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
|
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass definition"""
|
||||||
model = models.PartCategory
|
model = models.PartCategory
|
||||||
skip_unchanged = True
|
skip_unchanged = True
|
||||||
report_skipped = False
|
report_skipped = False
|
||||||
@ -102,6 +115,7 @@ class PartCategoryResource(ModelResource):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||||
|
"""Rebuild MPTT tree structure after importing PartCategory data"""
|
||||||
|
|
||||||
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
|
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
|
||||||
|
|
||||||
@ -110,6 +124,7 @@ class PartCategoryResource(ModelResource):
|
|||||||
|
|
||||||
|
|
||||||
class PartCategoryAdmin(ImportExportModelAdmin):
|
class PartCategoryAdmin(ImportExportModelAdmin):
|
||||||
|
"""Admin class for the PartCategory model"""
|
||||||
|
|
||||||
resource_class = PartCategoryResource
|
resource_class = PartCategoryResource
|
||||||
|
|
||||||
@ -127,27 +142,15 @@ class PartRelatedAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class PartAttachmentAdmin(admin.ModelAdmin):
|
class PartAttachmentAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin class for the PartAttachment model"""
|
||||||
|
|
||||||
list_display = ('part', 'attachment', 'comment')
|
list_display = ('part', 'attachment', 'comment')
|
||||||
|
|
||||||
autocomplete_fields = ('part',)
|
autocomplete_fields = ('part',)
|
||||||
|
|
||||||
|
|
||||||
class PartStarAdmin(admin.ModelAdmin):
|
|
||||||
|
|
||||||
list_display = ('part', 'user')
|
|
||||||
|
|
||||||
autocomplete_fields = ('part',)
|
|
||||||
|
|
||||||
|
|
||||||
class PartCategoryStarAdmin(admin.ModelAdmin):
|
|
||||||
|
|
||||||
list_display = ('category', 'user')
|
|
||||||
|
|
||||||
autocomplete_fields = ('category',)
|
|
||||||
|
|
||||||
|
|
||||||
class PartTestTemplateAdmin(admin.ModelAdmin):
|
class PartTestTemplateAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin class for the PartTestTemplate model"""
|
||||||
|
|
||||||
list_display = ('part', 'test_name', 'required')
|
list_display = ('part', 'test_name', 'required')
|
||||||
|
|
||||||
@ -193,7 +196,7 @@ class BomItemResource(ModelResource):
|
|||||||
return float(item.quantity)
|
return float(item.quantity)
|
||||||
|
|
||||||
def before_export(self, queryset, *args, **kwargs):
|
def before_export(self, queryset, *args, **kwargs):
|
||||||
|
"""Perform before exporting data"""
|
||||||
self.is_importing = kwargs.get('importing', False)
|
self.is_importing = kwargs.get('importing', False)
|
||||||
|
|
||||||
def get_fields(self, **kwargs):
|
def get_fields(self, **kwargs):
|
||||||
@ -229,6 +232,7 @@ class BomItemResource(ModelResource):
|
|||||||
return fields
|
return fields
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass definition"""
|
||||||
model = models.BomItem
|
model = models.BomItem
|
||||||
skip_unchanged = True
|
skip_unchanged = True
|
||||||
report_skipped = False
|
report_skipped = False
|
||||||
@ -243,6 +247,7 @@ class BomItemResource(ModelResource):
|
|||||||
|
|
||||||
|
|
||||||
class BomItemAdmin(ImportExportModelAdmin):
|
class BomItemAdmin(ImportExportModelAdmin):
|
||||||
|
"""Admin class for the BomItem model"""
|
||||||
|
|
||||||
resource_class = BomItemResource
|
resource_class = BomItemResource
|
||||||
|
|
||||||
@ -254,6 +259,8 @@ class BomItemAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ParameterTemplateAdmin(ImportExportModelAdmin):
|
class ParameterTemplateAdmin(ImportExportModelAdmin):
|
||||||
|
"""Admin class for the PartParameterTemplate model"""
|
||||||
|
|
||||||
list_display = ('name', 'units')
|
list_display = ('name', 'units')
|
||||||
|
|
||||||
search_fields = ('name', 'units')
|
search_fields = ('name', 'units')
|
||||||
@ -271,6 +278,7 @@ class ParameterResource(ModelResource):
|
|||||||
template_name = Field(attribute='template__name', readonly=True)
|
template_name = Field(attribute='template__name', readonly=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass definition"""
|
||||||
model = models.PartParameter
|
model = models.PartParameter
|
||||||
skip_unchanged = True
|
skip_unchanged = True
|
||||||
report_skipped = False
|
report_skipped = False
|
||||||
@ -278,6 +286,7 @@ class ParameterResource(ModelResource):
|
|||||||
|
|
||||||
|
|
||||||
class ParameterAdmin(ImportExportModelAdmin):
|
class ParameterAdmin(ImportExportModelAdmin):
|
||||||
|
"""Admin class for the PartParameter model"""
|
||||||
|
|
||||||
resource_class = ParameterResource
|
resource_class = ParameterResource
|
||||||
|
|
||||||
@ -287,21 +296,26 @@ class ParameterAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class PartCategoryParameterAdmin(admin.ModelAdmin):
|
class PartCategoryParameterAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin class for the PartCategoryParameterTemplate model"""
|
||||||
|
|
||||||
autocomplete_fields = ('category', 'parameter_template',)
|
autocomplete_fields = ('category', 'parameter_template',)
|
||||||
|
|
||||||
|
|
||||||
class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin class for the PartSellPriceBreak model"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass definition"""
|
||||||
model = models.PartSellPriceBreak
|
model = models.PartSellPriceBreak
|
||||||
|
|
||||||
list_display = ('part', 'quantity', 'price',)
|
list_display = ('part', 'quantity', 'price',)
|
||||||
|
|
||||||
|
|
||||||
class PartInternalPriceBreakAdmin(admin.ModelAdmin):
|
class PartInternalPriceBreakAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin class for the PartInternalPriceBreak model"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass definition"""
|
||||||
model = models.PartInternalPriceBreak
|
model = models.PartInternalPriceBreak
|
||||||
|
|
||||||
list_display = ('part', 'quantity', 'price',)
|
list_display = ('part', 'quantity', 'price',)
|
||||||
@ -313,8 +327,6 @@ admin.site.register(models.Part, PartAdmin)
|
|||||||
admin.site.register(models.PartCategory, PartCategoryAdmin)
|
admin.site.register(models.PartCategory, PartCategoryAdmin)
|
||||||
admin.site.register(models.PartRelated, PartRelatedAdmin)
|
admin.site.register(models.PartRelated, PartRelatedAdmin)
|
||||||
admin.site.register(models.PartAttachment, PartAttachmentAdmin)
|
admin.site.register(models.PartAttachment, PartAttachmentAdmin)
|
||||||
admin.site.register(models.PartStar, PartStarAdmin)
|
|
||||||
admin.site.register(models.PartCategoryStar, PartCategoryStarAdmin)
|
|
||||||
admin.site.register(models.BomItem, BomItemAdmin)
|
admin.site.register(models.BomItem, BomItemAdmin)
|
||||||
admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
|
admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
|
||||||
admin.site.register(models.PartParameter, ParameterAdmin)
|
admin.site.register(models.PartParameter, ParameterAdmin)
|
||||||
|
@ -49,7 +49,7 @@ class CategoryList(generics.ListCreateAPIView):
|
|||||||
serializer_class = part_serializers.CategorySerializer
|
serializer_class = part_serializers.CategorySerializer
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
|
"""Add extra context data to the serializer for the PartCategoryList endpoint"""
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -161,7 +161,7 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
queryset = PartCategory.objects.all()
|
queryset = PartCategory.objects.all()
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
|
"""Add extra context to the serializer for the CategoryDetail endpoint"""
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -173,7 +173,7 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
|
"""Perform 'update' function and mark this part as 'starred' (or not)"""
|
||||||
if 'starred' in request.data:
|
if 'starred' in request.data:
|
||||||
starred = str2bool(request.data.get('starred', False))
|
starred = str2bool(request.data.get('starred', False))
|
||||||
|
|
||||||
@ -188,6 +188,7 @@ class CategoryMetadata(generics.RetrieveUpdateAPIView):
|
|||||||
"""API endpoint for viewing / updating PartCategory metadata."""
|
"""API endpoint for viewing / updating PartCategory metadata."""
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Return a MetadataSerializer pointing to the referenced PartCategory instance"""
|
||||||
return MetadataSerializer(PartCategory, *args, **kwargs)
|
return MetadataSerializer(PartCategory, *args, **kwargs)
|
||||||
|
|
||||||
queryset = PartCategory.objects.all()
|
queryset = PartCategory.objects.all()
|
||||||
@ -370,7 +371,7 @@ class PartThumbs(generics.ListAPIView):
|
|||||||
serializer_class = part_serializers.PartThumbSerializer
|
serializer_class = part_serializers.PartThumbSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
"""Return a queryset which exlcudes any parts without images"""
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
# Get all Parts which have an associated image
|
# Get all Parts which have an associated image
|
||||||
@ -432,7 +433,7 @@ class PartScheduling(generics.RetrieveAPIView):
|
|||||||
queryset = Part.objects.all()
|
queryset = Part.objects.all()
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
"""Return scheduling information for the referenced Part instance"""
|
||||||
today = datetime.datetime.now().date()
|
today = datetime.datetime.now().date()
|
||||||
|
|
||||||
part = self.get_object()
|
part = self.get_object()
|
||||||
@ -555,6 +556,7 @@ class PartMetadata(generics.RetrieveUpdateAPIView):
|
|||||||
"""API endpoint for viewing / updating Part metadata."""
|
"""API endpoint for viewing / updating Part metadata."""
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Returns a MetadataSerializer instance pointing to the referenced Part"""
|
||||||
return MetadataSerializer(Part, *args, **kwargs)
|
return MetadataSerializer(Part, *args, **kwargs)
|
||||||
|
|
||||||
queryset = Part.objects.all()
|
queryset = Part.objects.all()
|
||||||
@ -566,7 +568,7 @@ class PartSerialNumberDetail(generics.RetrieveAPIView):
|
|||||||
queryset = Part.objects.all()
|
queryset = Part.objects.all()
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
"""Return serial number information for the referenced Part instance"""
|
||||||
part = self.get_object()
|
part = self.get_object()
|
||||||
|
|
||||||
# Calculate the "latest" serial number
|
# Calculate the "latest" serial number
|
||||||
@ -592,7 +594,7 @@ class PartCopyBOM(generics.CreateAPIView):
|
|||||||
serializer_class = part_serializers.PartCopyBOMSerializer
|
serializer_class = part_serializers.PartCopyBOMSerializer
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
|
"""Add custom information to the serializer context for this endpoint"""
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -607,8 +609,10 @@ class PartValidateBOM(generics.RetrieveUpdateAPIView):
|
|||||||
"""API endpoint for 'validating' the BOM for a given Part."""
|
"""API endpoint for 'validating' the BOM for a given Part."""
|
||||||
|
|
||||||
class BOMValidateSerializer(serializers.ModelSerializer):
|
class BOMValidateSerializer(serializers.ModelSerializer):
|
||||||
|
"""Simple serializer class for validating a single BomItem instance"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defines serializer fields"""
|
||||||
model = Part
|
model = Part
|
||||||
fields = [
|
fields = [
|
||||||
'checksum',
|
'checksum',
|
||||||
@ -628,6 +632,7 @@ class PartValidateBOM(generics.RetrieveUpdateAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_valid(self, valid):
|
def validate_valid(self, valid):
|
||||||
|
"""Check that the 'valid' input was flagged"""
|
||||||
if not valid:
|
if not valid:
|
||||||
raise ValidationError(_('This option must be selected'))
|
raise ValidationError(_('This option must be selected'))
|
||||||
|
|
||||||
@ -636,7 +641,7 @@ class PartValidateBOM(generics.RetrieveUpdateAPIView):
|
|||||||
serializer_class = BOMValidateSerializer
|
serializer_class = BOMValidateSerializer
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
|
"""Validate the referenced BomItem instance"""
|
||||||
part = self.get_object()
|
part = self.get_object()
|
||||||
|
|
||||||
partial = kwargs.pop('partial', False)
|
partial = kwargs.pop('partial', False)
|
||||||
@ -660,6 +665,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
starred_parts = None
|
starred_parts = None
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
"""Return an annotated queryset object for the PartDetail endpoint"""
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
|
|
||||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||||
@ -667,7 +673,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Return a serializer instance for the PartDetail endpoint"""
|
||||||
# By default, include 'category_detail' information in the detail view
|
# By default, include 'category_detail' information in the detail view
|
||||||
try:
|
try:
|
||||||
kwargs['category_detail'] = str2bool(self.request.query_params.get('category_detail', True))
|
kwargs['category_detail'] = str2bool(self.request.query_params.get('category_detail', True))
|
||||||
@ -687,7 +693,11 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
# Retrieve part
|
"""Delete a Part instance via the API
|
||||||
|
|
||||||
|
- If the part is 'active' it cannot be deleted
|
||||||
|
- It must first be marked as 'inactive'
|
||||||
|
"""
|
||||||
part = Part.objects.get(pk=int(kwargs['pk']))
|
part = Part.objects.get(pk=int(kwargs['pk']))
|
||||||
# Check if inactive
|
# Check if inactive
|
||||||
if not part.active:
|
if not part.active:
|
||||||
@ -695,7 +705,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return super(PartDetail, self).destroy(request, *args, **kwargs)
|
return super(PartDetail, self).destroy(request, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
# Return 405 error
|
# Return 405 error
|
||||||
message = f'Part \'{part.name}\' (pk = {part.pk}) is active: cannot delete'
|
message = 'Part is active: cannot delete'
|
||||||
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
|
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
@ -723,7 +733,7 @@ class PartFilter(rest_filters.FilterSet):
|
|||||||
has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn')
|
has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn')
|
||||||
|
|
||||||
def filter_has_ipn(self, queryset, name, value):
|
def filter_has_ipn(self, queryset, name, value):
|
||||||
|
"""Filter by whether the Part has an IPN (internal part number) or not"""
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
|
|
||||||
if value:
|
if value:
|
||||||
@ -768,7 +778,7 @@ class PartFilter(rest_filters.FilterSet):
|
|||||||
has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
|
has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
|
||||||
|
|
||||||
def filter_has_stock(self, queryset, name, value):
|
def filter_has_stock(self, queryset, name, value):
|
||||||
|
"""Filter by whether the Part has any stock"""
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
|
|
||||||
if value:
|
if value:
|
||||||
@ -782,7 +792,7 @@ class PartFilter(rest_filters.FilterSet):
|
|||||||
unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
|
unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
|
||||||
|
|
||||||
def filter_unallocated_stock(self, queryset, name, value):
|
def filter_unallocated_stock(self, queryset, name, value):
|
||||||
|
"""Filter by whether the Part has unallocated stock"""
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
|
|
||||||
if value:
|
if value:
|
||||||
@ -837,7 +847,7 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
starred_parts = None
|
starred_parts = None
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Return a serializer instance for this endpoint"""
|
||||||
# Ensure the request context is passed through
|
# Ensure the request context is passed through
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
@ -859,6 +869,7 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
def download_queryset(self, queryset, export_format):
|
def download_queryset(self, queryset, export_format):
|
||||||
|
"""Download the filtered queryset as a data file"""
|
||||||
dataset = PartResource().export(queryset=queryset)
|
dataset = PartResource().export(queryset=queryset)
|
||||||
|
|
||||||
filedata = dataset.export(export_format)
|
filedata = dataset.export(export_format)
|
||||||
@ -1077,17 +1088,14 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
"""Return an annotated queryset object"""
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""Perform custom filtering of the queryset.
|
"""Perform custom filtering of the queryset"""
|
||||||
|
|
||||||
We overide the DRF filter_fields here because
|
|
||||||
"""
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
@ -1345,7 +1353,7 @@ class PartRelatedList(generics.ListCreateAPIView):
|
|||||||
serializer_class = part_serializers.PartRelationSerializer
|
serializer_class = part_serializers.PartRelationSerializer
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
|
"""Custom queryset filtering"""
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -1478,8 +1486,7 @@ class BomFilter(rest_filters.FilterSet):
|
|||||||
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
|
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
|
||||||
|
|
||||||
def filter_validated(self, queryset, name, value):
|
def filter_validated(self, queryset, name, value):
|
||||||
|
"""Filter by which lines have actually been validated"""
|
||||||
# Work out which lines have actually been validated
|
|
||||||
pks = []
|
pks = []
|
||||||
|
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
@ -1512,6 +1519,7 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
filterset_class = BomFilter
|
filterset_class = BomFilter
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
|
"""Return serialized list response for this endpoint"""
|
||||||
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
|
||||||
@ -1537,6 +1545,13 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Return the serializer instance for this API endpoint
|
||||||
|
|
||||||
|
If requested, extra detail fields are annotated to the queryset:
|
||||||
|
- part_detail
|
||||||
|
- sub_part_detail
|
||||||
|
- include_pricing
|
||||||
|
"""
|
||||||
|
|
||||||
# Do we wish to include extra detail?
|
# Do we wish to include extra detail?
|
||||||
try:
|
try:
|
||||||
@ -1561,7 +1576,7 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
"""Return the queryset object for this endpoint"""
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
|
|
||||||
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
||||||
@ -1570,7 +1585,7 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
|
"""Custom query filtering for the BomItem list API"""
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -1766,7 +1781,7 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = part_serializers.BomItemSerializer
|
serializer_class = part_serializers.BomItemSerializer
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
"""Prefetch related fields for this queryset"""
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
|
|
||||||
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
||||||
@ -1778,9 +1793,8 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
class BomItemValidate(generics.UpdateAPIView):
|
class BomItemValidate(generics.UpdateAPIView):
|
||||||
"""API endpoint for validating a BomItem."""
|
"""API endpoint for validating a BomItem."""
|
||||||
|
|
||||||
# Very simple serializers
|
|
||||||
class BomItemValidationSerializer(serializers.Serializer):
|
class BomItemValidationSerializer(serializers.Serializer):
|
||||||
|
"""Simple serializer for passing a single boolean field"""
|
||||||
valid = serializers.BooleanField(default=False)
|
valid = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
queryset = BomItem.objects.all()
|
queryset = BomItem.objects.all()
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
"""part app specification"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
@ -9,6 +11,7 @@ logger = logging.getLogger("inventree")
|
|||||||
|
|
||||||
|
|
||||||
class PartConfig(AppConfig):
|
class PartConfig(AppConfig):
|
||||||
|
"""Config class for the 'part' app"""
|
||||||
name = 'part'
|
name = 'part'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
|
@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from mptt.fields import TreeNodeChoiceField
|
from mptt.fields import TreeNodeChoiceField
|
||||||
|
|
||||||
import common.models
|
|
||||||
from common.forms import MatchItemForm
|
from common.forms import MatchItemForm
|
||||||
from InvenTree.fields import RoundingDecimalFormField
|
from InvenTree.fields import RoundingDecimalFormField
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
@ -16,20 +15,6 @@ from .models import (Part, PartCategory, PartCategoryParameterTemplate,
|
|||||||
PartSellPriceBreak)
|
PartSellPriceBreak)
|
||||||
|
|
||||||
|
|
||||||
class PartModelChoiceField(forms.ModelChoiceField):
|
|
||||||
"""Extending string representation of Part instance with available stock."""
|
|
||||||
|
|
||||||
def label_from_instance(self, part):
|
|
||||||
|
|
||||||
label = str(part)
|
|
||||||
|
|
||||||
# Optionally display available part quantity
|
|
||||||
if common.models.InvenTreeSetting.get_setting('PART_SHOW_QUANTITY_IN_FORMS'):
|
|
||||||
label += f" - {part.available_stock}"
|
|
||||||
|
|
||||||
return label
|
|
||||||
|
|
||||||
|
|
||||||
class PartImageDownloadForm(HelperForm):
|
class PartImageDownloadForm(HelperForm):
|
||||||
"""Form for downloading an image from a URL."""
|
"""Form for downloading an image from a URL."""
|
||||||
|
|
||||||
@ -40,6 +25,7 @@ class PartImageDownloadForm(HelperForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defines fields for this form"""
|
||||||
model = Part
|
model = Part
|
||||||
fields = [
|
fields = [
|
||||||
'url',
|
'url',
|
||||||
@ -78,6 +64,7 @@ class EditPartParameterTemplateForm(HelperForm):
|
|||||||
"""Form for editing a PartParameterTemplate object."""
|
"""Form for editing a PartParameterTemplate object."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defines fields for this form"""
|
||||||
model = PartParameterTemplate
|
model = PartParameterTemplate
|
||||||
fields = [
|
fields = [
|
||||||
'name',
|
'name',
|
||||||
@ -97,6 +84,7 @@ class EditCategoryParameterTemplateForm(HelperForm):
|
|||||||
help_text=_('Add parameter template to all categories'))
|
help_text=_('Add parameter template to all categories'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defines fields for this form"""
|
||||||
model = PartCategoryParameterTemplate
|
model = PartCategoryParameterTemplate
|
||||||
fields = [
|
fields = [
|
||||||
'category',
|
'category',
|
||||||
@ -118,6 +106,7 @@ class PartPriceForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defines fields for this form"""
|
||||||
model = Part
|
model = Part
|
||||||
fields = [
|
fields = [
|
||||||
'quantity',
|
'quantity',
|
||||||
@ -130,6 +119,7 @@ class EditPartSalePriceBreakForm(HelperForm):
|
|||||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defines fields for this form"""
|
||||||
model = PartSellPriceBreak
|
model = PartSellPriceBreak
|
||||||
fields = [
|
fields = [
|
||||||
'part',
|
'part',
|
||||||
@ -144,6 +134,7 @@ class EditPartInternalPriceBreakForm(HelperForm):
|
|||||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defines fields for this form"""
|
||||||
model = PartInternalPriceBreak
|
model = PartInternalPriceBreak
|
||||||
fields = [
|
fields = [
|
||||||
'part',
|
'part',
|
||||||
|
@ -103,12 +103,15 @@ class PartCategory(MetadataMixin, InvenTreeTree):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Return the API url associated with the PartCategory model"""
|
||||||
return reverse('api-part-category-list')
|
return reverse('api-part-category-list')
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
|
"""Return the web URL associated with the detail view for this PartCategory instance"""
|
||||||
return reverse('category-detail', kwargs={'pk': self.id})
|
return reverse('category-detail', kwargs={'pk': self.id})
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defines extra model properties"""
|
||||||
verbose_name = _("Part Category")
|
verbose_name = _("Part Category")
|
||||||
verbose_name_plural = _("Part Categories")
|
verbose_name_plural = _("Part Categories")
|
||||||
|
|
||||||
@ -131,6 +134,7 @@ class PartCategory(MetadataMixin, InvenTreeTree):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def item_count(self):
|
def item_count(self):
|
||||||
|
"""Return the number of parts contained in this PartCategory"""
|
||||||
return self.partcount()
|
return self.partcount()
|
||||||
|
|
||||||
def partcount(self, cascade=True, active=False):
|
def partcount(self, cascade=True, active=False):
|
||||||
@ -284,7 +288,7 @@ class PartManager(TreeManager):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
"""Perform default prefetch operations when accessing Part model from the database"""
|
||||||
return super().get_queryset().prefetch_related(
|
return super().get_queryset().prefetch_related(
|
||||||
'category',
|
'category',
|
||||||
'category__parent',
|
'category__parent',
|
||||||
@ -333,6 +337,7 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
objects = PartManager()
|
objects = PartManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defines extra model properties"""
|
||||||
verbose_name = _("Part")
|
verbose_name = _("Part")
|
||||||
verbose_name_plural = _("Parts")
|
verbose_name_plural = _("Parts")
|
||||||
ordering = ['name', ]
|
ordering = ['name', ]
|
||||||
@ -341,12 +346,13 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
]
|
]
|
||||||
|
|
||||||
class MPTTMeta:
|
class MPTTMeta:
|
||||||
|
"""MPTT metaclass definitions"""
|
||||||
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
||||||
parent_attr = 'variant_of'
|
parent_attr = 'variant_of'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Return the list API endpoint URL associated with the Part model"""
|
||||||
return reverse('api-part-list')
|
return reverse('api-part-list')
|
||||||
|
|
||||||
def api_instance_filters(self):
|
def api_instance_filters(self):
|
||||||
@ -450,6 +456,7 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
"""Return a string representation of the Part (for use in the admin interface)"""
|
||||||
return f"{self.full_name} - {self.description}"
|
return f"{self.full_name} - {self.description}"
|
||||||
|
|
||||||
def get_parts_in_bom(self, **kwargs):
|
def get_parts_in_bom(self, **kwargs):
|
||||||
@ -665,15 +672,6 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
return ' | '.join(elements)
|
return ' | '.join(elements)
|
||||||
|
|
||||||
def set_category(self, category):
|
|
||||||
|
|
||||||
# Ignore if the category is already the same
|
|
||||||
if self.category == category:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.category = category
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
"""Return the web URL for viewing this part."""
|
"""Return the web URL for viewing this part."""
|
||||||
return reverse('part-detail', kwargs={'pk': self.id})
|
return reverse('part-detail', kwargs={'pk': self.id})
|
||||||
@ -956,6 +954,7 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def category_path(self):
|
def category_path(self):
|
||||||
|
"""Return the category path of this Part instance"""
|
||||||
if self.category:
|
if self.category:
|
||||||
return self.category.pathstring
|
return self.category.pathstring
|
||||||
return ''
|
return ''
|
||||||
@ -1442,6 +1441,7 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def has_bom(self):
|
def has_bom(self):
|
||||||
|
"""Return True if this Part instance has any BOM items"""
|
||||||
return self.get_bom_items().count() > 0
|
return self.get_bom_items().count() > 0
|
||||||
|
|
||||||
def get_trackable_parts(self):
|
def get_trackable_parts(self):
|
||||||
@ -1605,7 +1605,17 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
return "{a} - {b}".format(a=min_price, b=max_price)
|
return "{a} - {b}".format(a=min_price, b=max_price)
|
||||||
|
|
||||||
def get_supplier_price_range(self, quantity=1):
|
def get_supplier_price_range(self, quantity=1):
|
||||||
|
"""Return the supplier price range of this part:
|
||||||
|
|
||||||
|
- Checks if there is any supplier pricing information associated with this Part
|
||||||
|
- Iterate through available supplier pricing and select (min, max)
|
||||||
|
- Returns tuple of (min, max)
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
quantity: Quantity at which to calculate price (default=1)
|
||||||
|
|
||||||
|
Returns: (min, max) tuple or (None, None) if no supplier pricing available
|
||||||
|
"""
|
||||||
min_price = None
|
min_price = None
|
||||||
max_price = None
|
max_price = None
|
||||||
|
|
||||||
@ -1719,6 +1729,7 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def has_price_breaks(self):
|
def has_price_breaks(self):
|
||||||
|
"""Return True if this part has sale price breaks"""
|
||||||
return self.price_breaks.count() > 0
|
return self.price_breaks.count() > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -1728,6 +1739,7 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def unit_pricing(self):
|
def unit_pricing(self):
|
||||||
|
"""Returns the price of this Part at quantity=1"""
|
||||||
return self.get_price(1)
|
return self.get_price(1)
|
||||||
|
|
||||||
def add_price_break(self, quantity, price):
|
def add_price_break(self, quantity, price):
|
||||||
@ -1748,10 +1760,12 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_internal_price(self, quantity, moq=True, multiples=True, currency=None):
|
def get_internal_price(self, quantity, moq=True, multiples=True, currency=None):
|
||||||
|
"""Return the internal price of this Part at the specified quantity"""
|
||||||
return common.models.get_price(self, quantity, moq, multiples, currency, break_name='internal_price_breaks')
|
return common.models.get_price(self, quantity, moq, multiples, currency, break_name='internal_price_breaks')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_internal_price_breaks(self):
|
def has_internal_price_breaks(self):
|
||||||
|
"""Return True if this Part has internal pricing information"""
|
||||||
return self.internal_price_breaks.count() > 0
|
return self.internal_price_breaks.count() > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -1759,11 +1773,12 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
"""Return the associated price breaks in the correct order."""
|
"""Return the associated price breaks in the correct order."""
|
||||||
return self.internalpricebreaks.order_by('quantity').all()
|
return self.internalpricebreaks.order_by('quantity').all()
|
||||||
|
|
||||||
@property
|
|
||||||
def internal_unit_pricing(self):
|
|
||||||
return self.get_internal_price(1)
|
|
||||||
|
|
||||||
def get_purchase_price(self, quantity):
|
def get_purchase_price(self, quantity):
|
||||||
|
"""Calculate the purchase price for this part at the specified quantity
|
||||||
|
|
||||||
|
- Looks at available supplier pricing data
|
||||||
|
- Calculates the price base on the closest price point
|
||||||
|
"""
|
||||||
currency = currency_code_default()
|
currency = currency_code_default()
|
||||||
try:
|
try:
|
||||||
prices = [convert_money(item.purchase_price, currency).amount for item in self.stock_items.all() if item.purchase_price]
|
prices = [convert_money(item.purchase_price, currency).amount for item in self.stock_items.all() if item.purchase_price]
|
||||||
@ -1843,7 +1858,7 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def copy_parameters_from(self, other, **kwargs):
|
def copy_parameters_from(self, other, **kwargs):
|
||||||
|
"""Copy all parameter values from another Part instance"""
|
||||||
clear = kwargs.get('clear', True)
|
clear = kwargs.get('clear', True)
|
||||||
|
|
||||||
if clear:
|
if clear:
|
||||||
@ -1920,12 +1935,9 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
return tests
|
return tests
|
||||||
|
|
||||||
def getRequiredTests(self):
|
def getRequiredTests(self):
|
||||||
# Return the tests which are required by this part
|
"""Return the tests which are required by this part"""
|
||||||
return self.getTestTemplates(required=True)
|
return self.getTestTemplates(required=True)
|
||||||
|
|
||||||
def requiredTestCount(self):
|
|
||||||
return self.getRequiredTests().count()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def attachment_count(self):
|
def attachment_count(self):
|
||||||
"""Count the number of attachments for this part.
|
"""Count the number of attachments for this part.
|
||||||
@ -2081,18 +2093,21 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
related_parts_2 = self.related_parts_2.filter(part_2__id=self.pk)
|
related_parts_2 = self.related_parts_2.filter(part_2__id=self.pk)
|
||||||
|
|
||||||
|
related_parts.append()
|
||||||
|
|
||||||
for related_part in related_parts_1:
|
for related_part in related_parts_1:
|
||||||
# Add to related parts list
|
# Add to related parts list
|
||||||
related_parts.append((related_part, related_part.part_2))
|
related_parts.append(related_part.part_2)
|
||||||
|
|
||||||
for related_part in related_parts_2:
|
for related_part in related_parts_2:
|
||||||
# Add to related parts list
|
# Add to related parts list
|
||||||
related_parts.append((related_part, related_part.part_1))
|
related_parts.append(related_part.part_1)
|
||||||
|
|
||||||
return related_parts
|
return related_parts
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def related_count(self):
|
def related_count(self):
|
||||||
|
"""Return the number of 'related parts' which point to this Part"""
|
||||||
return len(self.get_related_parts())
|
return len(self.get_related_parts())
|
||||||
|
|
||||||
def is_part_low_on_stock(self):
|
def is_part_low_on_stock(self):
|
||||||
@ -2117,9 +2132,11 @@ class PartAttachment(InvenTreeAttachment):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Return the list API endpoint URL associated with the PartAttachment model"""
|
||||||
return reverse('api-part-attachment-list')
|
return reverse('api-part-attachment-list')
|
||||||
|
|
||||||
def getSubdir(self):
|
def getSubdir(self):
|
||||||
|
"""Returns the media subdirectory where part attachments are stored"""
|
||||||
return os.path.join("part_files", str(self.part.id))
|
return os.path.join("part_files", str(self.part.id))
|
||||||
|
|
||||||
part = models.ForeignKey(Part, on_delete=models.CASCADE,
|
part = models.ForeignKey(Part, on_delete=models.CASCADE,
|
||||||
@ -2131,6 +2148,7 @@ class PartSellPriceBreak(common.models.PriceBreak):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Return the list API endpoint URL associated with the PartSellPriceBreak model"""
|
||||||
return reverse('api-part-sale-price-list')
|
return reverse('api-part-sale-price-list')
|
||||||
|
|
||||||
part = models.ForeignKey(
|
part = models.ForeignKey(
|
||||||
@ -2141,6 +2159,7 @@ class PartSellPriceBreak(common.models.PriceBreak):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass providing extra model definition"""
|
||||||
unique_together = ('part', 'quantity')
|
unique_together = ('part', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
@ -2149,6 +2168,7 @@ class PartInternalPriceBreak(common.models.PriceBreak):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Return the list API endpoint URL associated with the PartInternalPriceBreak model"""
|
||||||
return reverse('api-part-internal-price-list')
|
return reverse('api-part-internal-price-list')
|
||||||
|
|
||||||
part = models.ForeignKey(
|
part = models.ForeignKey(
|
||||||
@ -2158,6 +2178,7 @@ class PartInternalPriceBreak(common.models.PriceBreak):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass providing extra model definition"""
|
||||||
unique_together = ('part', 'quantity')
|
unique_together = ('part', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
@ -2176,6 +2197,7 @@ class PartStar(models.Model):
|
|||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts')
|
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass providing extra model definition"""
|
||||||
unique_together = [
|
unique_together = [
|
||||||
'part',
|
'part',
|
||||||
'user'
|
'user'
|
||||||
@ -2195,6 +2217,7 @@ class PartCategoryStar(models.Model):
|
|||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories')
|
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass providing extra model definition"""
|
||||||
unique_together = [
|
unique_together = [
|
||||||
'category',
|
'category',
|
||||||
'user',
|
'user',
|
||||||
@ -2216,16 +2239,17 @@ class PartTestTemplate(models.Model):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Return the list API endpoint URL associated with the PartTestTemplate model"""
|
||||||
return reverse('api-part-test-template-list')
|
return reverse('api-part-test-template-list')
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
"""Enforce 'clean' operation when saving a PartTestTemplate instance"""
|
||||||
self.clean()
|
self.clean()
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
"""Clean fields for the PartTestTemplate model"""
|
||||||
self.test_name = self.test_name.strip()
|
self.test_name = self.test_name.strip()
|
||||||
|
|
||||||
self.validate_unique()
|
self.validate_unique()
|
||||||
@ -2320,9 +2344,11 @@ class PartParameterTemplate(models.Model):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Return the list API endpoint URL associated with the PartParameterTemplate model"""
|
||||||
return reverse('api-part-parameter-template-list')
|
return reverse('api-part-parameter-template-list')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
"""Return a string representation of a PartParameterTemplate instance"""
|
||||||
s = str(self.name)
|
s = str(self.name)
|
||||||
if self.units:
|
if self.units:
|
||||||
s += " ({units})".format(units=self.units)
|
s += " ({units})".format(units=self.units)
|
||||||
@ -2368,10 +2394,11 @@ class PartParameter(models.Model):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Return the list API endpoint URL associated with the PartParameter model"""
|
||||||
return reverse('api-part-parameter-list')
|
return reverse('api-part-parameter-list')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
# String representation of a PartParameter (used in the admin interface)
|
"""String representation of a PartParameter (used in the admin interface)"""
|
||||||
return "{part} : {param} = {data}{units}".format(
|
return "{part} : {param} = {data}{units}".format(
|
||||||
part=str(self.part.full_name),
|
part=str(self.part.full_name),
|
||||||
param=str(self.template.name),
|
param=str(self.template.name),
|
||||||
@ -2380,6 +2407,7 @@ class PartParameter(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass providing extra model definition"""
|
||||||
# Prevent multiple instances of a parameter for a single part
|
# Prevent multiple instances of a parameter for a single part
|
||||||
unique_together = ('part', 'template')
|
unique_together = ('part', 'template')
|
||||||
|
|
||||||
@ -2391,6 +2419,7 @@ class PartParameter(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, part, template, data, save=False):
|
def create(cls, part, template, data, save=False):
|
||||||
|
"""Custom save method for the PartParameter class"""
|
||||||
part_parameter = cls(part=part, template=template, data=data)
|
part_parameter = cls(part=part, template=template, data=data)
|
||||||
if save:
|
if save:
|
||||||
part_parameter.save()
|
part_parameter.save()
|
||||||
@ -2408,6 +2437,7 @@ class PartCategoryParameterTemplate(models.Model):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass providing extra model definition"""
|
||||||
constraints = [
|
constraints = [
|
||||||
UniqueConstraint(fields=['category', 'parameter_template'],
|
UniqueConstraint(fields=['category', 'parameter_template'],
|
||||||
name='unique_category_parameter_template_pair')
|
name='unique_category_parameter_template_pair')
|
||||||
@ -2438,7 +2468,7 @@ class PartCategoryParameterTemplate(models.Model):
|
|||||||
help_text=_('Default Parameter Value'))
|
help_text=_('Default Parameter Value'))
|
||||||
|
|
||||||
|
|
||||||
class BomItem(models.Model, DataImportMixin):
|
class BomItem(DataImportMixin, models.Model):
|
||||||
"""A BomItem links a part to its component items.
|
"""A BomItem links a part to its component items.
|
||||||
|
|
||||||
A part can have a BOM (bill of materials) which defines
|
A part can have a BOM (bill of materials) which defines
|
||||||
@ -2492,6 +2522,7 @@ class BomItem(models.Model, DataImportMixin):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Return the list API endpoint URL associated with the BomItem model"""
|
||||||
return reverse('api-bom-list')
|
return reverse('api-bom-list')
|
||||||
|
|
||||||
def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True):
|
def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True):
|
||||||
@ -2546,7 +2577,7 @@ class BomItem(models.Model, DataImportMixin):
|
|||||||
return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
|
return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
"""Enforce 'clean' operation when saving a BomItem instance"""
|
||||||
self.clean()
|
self.clean()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@ -2686,12 +2717,14 @@ class BomItem(models.Model, DataImportMixin):
|
|||||||
raise ValidationError({'sub_part': _('Sub part must be specified')})
|
raise ValidationError({'sub_part': _('Sub part must be specified')})
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass providing extra model definition"""
|
||||||
verbose_name = _("BOM Item")
|
verbose_name = _("BOM Item")
|
||||||
|
|
||||||
# Prevent duplication of parent/child rows
|
# Prevent duplication of parent/child rows
|
||||||
unique_together = ('part', 'sub_part')
|
unique_together = ('part', 'sub_part')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
"""Return a string representation of this BomItem instance"""
|
||||||
return "{n} x {child} to make {parent}".format(
|
return "{n} x {child} to make {parent}".format(
|
||||||
parent=self.part.full_name,
|
parent=self.part.full_name,
|
||||||
child=self.sub_part.full_name,
|
child=self.sub_part.full_name,
|
||||||
@ -2788,13 +2821,14 @@ class BomItemSubstitute(models.Model):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass providing extra model definition"""
|
||||||
verbose_name = _("BOM Item Substitute")
|
verbose_name = _("BOM Item Substitute")
|
||||||
|
|
||||||
# Prevent duplication of substitute parts
|
# Prevent duplication of substitute parts
|
||||||
unique_together = ('part', 'bom_item')
|
unique_together = ('part', 'bom_item')
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
"""Enforce a full_clean when saving the BomItemSubstitute model"""
|
||||||
self.full_clean()
|
self.full_clean()
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
@ -2814,6 +2848,7 @@ class BomItemSubstitute(models.Model):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
"""Returns the list API endpoint URL associated with this model"""
|
||||||
return reverse('api-bom-substitute-list')
|
return reverse('api-bom-substitute-list')
|
||||||
|
|
||||||
bom_item = models.ForeignKey(
|
bom_item = models.ForeignKey(
|
||||||
@ -2847,6 +2882,7 @@ class PartRelated(models.Model):
|
|||||||
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
|
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
"""Return a string representation of this Part-Part relationship"""
|
||||||
return f'{self.part_1} <--> {self.part_2}'
|
return f'{self.part_1} <--> {self.part_2}'
|
||||||
|
|
||||||
def validate(self, part_1, part_2):
|
def validate(self, part_1, part_2):
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""JSON serializers for Part app."""
|
"""DRF data serializers for Part app."""
|
||||||
|
|
||||||
import imghdr
|
import imghdr
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@ -37,10 +37,6 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
|
|||||||
class CategorySerializer(InvenTreeModelSerializer):
|
class CategorySerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer for PartCategory."""
|
"""Serializer for PartCategory."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_starred(self, category):
|
def get_starred(self, category):
|
||||||
"""Return True if the category is directly "starred" by the current user."""
|
"""Return True if the category is directly "starred" by the current user."""
|
||||||
return category in self.context.get('starred_categories', [])
|
return category in self.context.get('starred_categories', [])
|
||||||
@ -54,6 +50,7 @@ class CategorySerializer(InvenTreeModelSerializer):
|
|||||||
starred = serializers.SerializerMethodField()
|
starred = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartCategory
|
model = PartCategory
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -74,6 +71,7 @@ class CategoryTree(InvenTreeModelSerializer):
|
|||||||
"""Serializer for PartCategory tree."""
|
"""Serializer for PartCategory tree."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartCategory
|
model = PartCategory
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -86,6 +84,7 @@ class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
"""Serializer for the PartAttachment class."""
|
"""Serializer for the PartAttachment class."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartAttachment
|
model = PartAttachment
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
@ -109,6 +108,7 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
|
|||||||
key = serializers.CharField(read_only=True)
|
key = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartTestTemplate
|
model = PartTestTemplate
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
@ -142,6 +142,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
|||||||
price_string = serializers.CharField(source='price', read_only=True)
|
price_string = serializers.CharField(source='price', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartSellPriceBreak
|
model = PartSellPriceBreak
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -172,6 +173,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
|||||||
price_string = serializers.CharField(source='price', read_only=True)
|
price_string = serializers.CharField(source='price', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartInternalPriceBreak
|
model = PartInternalPriceBreak
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -206,6 +208,7 @@ class PartThumbSerializerUpdate(InvenTreeModelSerializer):
|
|||||||
image = InvenTreeAttachmentSerializerField(required=True)
|
image = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = Part
|
model = Part
|
||||||
fields = [
|
fields = [
|
||||||
'image',
|
'image',
|
||||||
@ -216,6 +219,7 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
|||||||
"""JSON serializer for the PartParameterTemplate model."""
|
"""JSON serializer for the PartParameterTemplate model."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartParameterTemplate
|
model = PartParameterTemplate
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -230,6 +234,7 @@ class PartParameterSerializer(InvenTreeModelSerializer):
|
|||||||
template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
|
template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartParameter
|
model = PartParameter
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -248,6 +253,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
|||||||
stock = serializers.FloatField(source='total_stock')
|
stock = serializers.FloatField(source='total_stock')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = Part
|
model = Part
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -277,10 +283,14 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def get_api_url(self):
|
def get_api_url(self):
|
||||||
|
"""Return the API url associated with this serializer"""
|
||||||
return reverse_lazy('api-part-list')
|
return reverse_lazy('api-part-list')
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Custom initialization method for PartSerializer, so that we can optionally pass extra fields based on the query."""
|
"""Custom initialization method for PartSerializer:
|
||||||
|
|
||||||
|
- Allows us to optionally pass extra fields based on the query.
|
||||||
|
"""
|
||||||
self.starred_parts = kwargs.pop('starred_parts', [])
|
self.starred_parts = kwargs.pop('starred_parts', [])
|
||||||
|
|
||||||
category_detail = kwargs.pop('category_detail', False)
|
category_detail = kwargs.pop('category_detail', False)
|
||||||
@ -452,6 +462,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = Part
|
model = Part
|
||||||
partial = True
|
partial = True
|
||||||
fields = [
|
fields = [
|
||||||
@ -503,6 +514,7 @@ class PartRelationSerializer(InvenTreeModelSerializer):
|
|||||||
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
|
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartRelated
|
model = PartRelated
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -520,6 +532,7 @@ class PartStarSerializer(InvenTreeModelSerializer):
|
|||||||
username = serializers.CharField(source='user.username', read_only=True)
|
username = serializers.CharField(source='user.username', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartStar
|
model = PartStar
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -536,6 +549,7 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer):
|
|||||||
part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
|
part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = BomItemSubstitute
|
model = BomItemSubstitute
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -553,6 +567,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
quantity = InvenTreeDecimalField(required=True)
|
quantity = InvenTreeDecimalField(required=True)
|
||||||
|
|
||||||
def validate_quantity(self, quantity):
|
def validate_quantity(self, quantity):
|
||||||
|
"""Perform validation for the BomItem quantity field"""
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
raise serializers.ValidationError(_("Quantity must be greater than zero"))
|
raise serializers.ValidationError(_("Quantity must be greater than zero"))
|
||||||
|
|
||||||
@ -584,9 +599,11 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
available_variant_stock = serializers.FloatField(read_only=True)
|
available_variant_stock = serializers.FloatField(read_only=True)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# part_detail and sub_part_detail serializers are only included if requested.
|
"""Determine if extra detail fields are to be annotated on this serializer
|
||||||
# This saves a bunch of database requests
|
|
||||||
|
|
||||||
|
- part_detail and sub_part_detail serializers are only included if requested.
|
||||||
|
- This saves a bunch of database requests
|
||||||
|
"""
|
||||||
part_detail = kwargs.pop('part_detail', False)
|
part_detail = kwargs.pop('part_detail', False)
|
||||||
sub_part_detail = kwargs.pop('sub_part_detail', False)
|
sub_part_detail = kwargs.pop('sub_part_detail', False)
|
||||||
include_pricing = kwargs.pop('include_pricing', False)
|
include_pricing = kwargs.pop('include_pricing', False)
|
||||||
@ -609,6 +626,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def setup_eager_loading(queryset):
|
def setup_eager_loading(queryset):
|
||||||
|
"""Prefetch against the provided queryset to speed up database access"""
|
||||||
queryset = queryset.prefetch_related('part')
|
queryset = queryset.prefetch_related('part')
|
||||||
queryset = queryset.prefetch_related('part__category')
|
queryset = queryset.prefetch_related('part__category')
|
||||||
queryset = queryset.prefetch_related('part__stock_items')
|
queryset = queryset.prefetch_related('part__stock_items')
|
||||||
@ -810,6 +828,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
return purchase_price_avg
|
return purchase_price_avg
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = BomItem
|
model = BomItem
|
||||||
fields = [
|
fields = [
|
||||||
'allow_variants',
|
'allow_variants',
|
||||||
@ -849,6 +868,7 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
|||||||
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
model = PartCategoryParameterTemplate
|
model = PartCategoryParameterTemplate
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -863,6 +883,7 @@ class PartCopyBOMSerializer(serializers.Serializer):
|
|||||||
"""Serializer for copying a BOM from another part."""
|
"""Serializer for copying a BOM from another part."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
fields = [
|
fields = [
|
||||||
'part',
|
'part',
|
||||||
'remove_existing',
|
'remove_existing',
|
||||||
@ -929,6 +950,7 @@ class BomImportUploadSerializer(DataFileUploadSerializer):
|
|||||||
TARGET_MODEL = BomItem
|
TARGET_MODEL = BomItem
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
"""Metaclass defining serializer fields"""
|
||||||
fields = [
|
fields = [
|
||||||
'data_file',
|
'data_file',
|
||||||
'part',
|
'part',
|
||||||
@ -948,7 +970,7 @@ class BomImportUploadSerializer(DataFileUploadSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
|
"""The uploaded data file has been validated, accept the submitted data"""
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
|
|
||||||
if data.get('clear_existing_bom', False):
|
if data.get('clear_existing_bom', False):
|
||||||
@ -959,11 +981,15 @@ class BomImportUploadSerializer(DataFileUploadSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class BomImportExtractSerializer(DataFileExtractSerializer):
|
class BomImportExtractSerializer(DataFileExtractSerializer):
|
||||||
""""""
|
"""Serializer class for exatracting BOM data from an uploaded file.
|
||||||
|
|
||||||
|
The parent class DataFileExtractSerializer does most of the heavy lifting here.
|
||||||
|
"""
|
||||||
|
|
||||||
TARGET_MODEL = BomItem
|
TARGET_MODEL = BomItem
|
||||||
|
|
||||||
def validate_extracted_columns(self):
|
def validate_extracted_columns(self):
|
||||||
|
"""Validate that the extracted columns are correct"""
|
||||||
super().validate_extracted_columns()
|
super().validate_extracted_columns()
|
||||||
|
|
||||||
part_columns = ['part', 'part_name', 'part_ipn', 'part_id']
|
part_columns = ['part', 'part_name', 'part_ipn', 'part_id']
|
||||||
@ -973,7 +999,7 @@ class BomImportExtractSerializer(DataFileExtractSerializer):
|
|||||||
raise serializers.ValidationError(_("No part column specified"))
|
raise serializers.ValidationError(_("No part column specified"))
|
||||||
|
|
||||||
def process_row(self, row):
|
def process_row(self, row):
|
||||||
|
"""Process a single row from the loaded BOM file"""
|
||||||
# Skip any rows which are at a lower "level"
|
# Skip any rows which are at a lower "level"
|
||||||
level = row.get('level', None)
|
level = row.get('level', None)
|
||||||
|
|
||||||
@ -1050,7 +1076,10 @@ class BomImportSubmitSerializer(serializers.Serializer):
|
|||||||
items = BomItemSerializer(many=True, required=True)
|
items = BomItemSerializer(many=True, required=True)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
"""Validate the submitted BomItem data:
|
||||||
|
|
||||||
|
- At least one line (BomItem) is required
|
||||||
|
"""
|
||||||
items = data['items']
|
items = data['items']
|
||||||
|
|
||||||
if len(items) == 0:
|
if len(items) == 0:
|
||||||
@ -1061,7 +1090,11 @@ class BomImportSubmitSerializer(serializers.Serializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
|
"""POST: Perform final save of submitted BOM data:
|
||||||
|
|
||||||
|
- By this stage each line in the BOM has been validated
|
||||||
|
- Individually 'save' (create) each BomItem line
|
||||||
|
"""
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
|
|
||||||
items = data['items']
|
items = data['items']
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
"""Background task definitions for the 'part' app"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -11,6 +13,11 @@ logger = logging.getLogger("inventree")
|
|||||||
|
|
||||||
|
|
||||||
def notify_low_stock(part: part.models.Part):
|
def notify_low_stock(part: part.models.Part):
|
||||||
|
"""Notify interested users that a part is 'low stock':
|
||||||
|
|
||||||
|
- Triggered when the available stock for a given part falls be low the configured threhsold
|
||||||
|
- A notification is delivered to any users who are 'subscribed' to this part
|
||||||
|
"""
|
||||||
name = _("Low stock notification")
|
name = _("Low stock notification")
|
||||||
message = _(f'The available stock for {part.name} has fallen below the configured minimum level')
|
message = _(f'The available stock for {part.name} has fallen below the configured minimum level')
|
||||||
context = {
|
context = {
|
||||||
@ -24,7 +31,7 @@ def notify_low_stock(part: part.models.Part):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
common.notifications.trigger_notifaction(
|
common.notifications.trigger_notification(
|
||||||
part,
|
part,
|
||||||
'part.notify_low_stock',
|
'part.notify_low_stock',
|
||||||
target_fnc=part.get_subscribers,
|
target_fnc=part.get_subscribers,
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
"""Custom InvenTree template tags for HTML template rendering"""
|
||||||
|
@ -106,18 +106,6 @@ def str2bool(x, *args, **kwargs):
|
|||||||
return InvenTree.helpers.str2bool(x)
|
return InvenTree.helpers.str2bool(x)
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
|
||||||
def inrange(n, *args, **kwargs):
|
|
||||||
"""Return range(n) for iterating through a numeric quantity."""
|
|
||||||
return range(n)
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
|
||||||
def multiply(x, y, *args, **kwargs):
|
|
||||||
"""Multiply two numbers together."""
|
|
||||||
return InvenTree.helpers.decimal2string(x * y)
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def add(x, y, *args, **kwargs):
|
def add(x, y, *args, **kwargs):
|
||||||
"""Add two numbers together."""
|
"""Add two numbers together."""
|
||||||
@ -211,16 +199,19 @@ def inventree_version(shortstring=False, *args, **kwargs):
|
|||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def inventree_is_development(*args, **kwargs):
|
def inventree_is_development(*args, **kwargs):
|
||||||
|
"""Returns True if this is a development version of InvenTree"""
|
||||||
return version.isInvenTreeDevelopmentVersion()
|
return version.isInvenTreeDevelopmentVersion()
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def inventree_is_release(*args, **kwargs):
|
def inventree_is_release(*args, **kwargs):
|
||||||
|
"""Returns True if this is a release version of InvenTree"""
|
||||||
return not version.isInvenTreeDevelopmentVersion()
|
return not version.isInvenTreeDevelopmentVersion()
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def inventree_docs_version(*args, **kwargs):
|
def inventree_docs_version(*args, **kwargs):
|
||||||
|
"""Returns the InvenTree documentation version"""
|
||||||
return version.inventreeDocsVersion()
|
return version.inventreeDocsVersion()
|
||||||
|
|
||||||
|
|
||||||
@ -367,6 +358,7 @@ def progress_bar(val, max_val, *args, **kwargs):
|
|||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def get_color_theme_css(username):
|
def get_color_theme_css(username):
|
||||||
|
"""Return the cutsom theme .css file for the selected user"""
|
||||||
user_theme_name = get_user_color_theme(username)
|
user_theme_name = get_user_color_theme(username)
|
||||||
# Build path to CSS sheet
|
# Build path to CSS sheet
|
||||||
inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css')
|
inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css')
|
||||||
@ -496,7 +488,7 @@ class I18nStaticNode(StaticNode):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def render(self, context): # pragma: no cover
|
def render(self, context): # pragma: no cover
|
||||||
|
"""Render this node with the determined locale context."""
|
||||||
self.original = getattr(self, 'original', None)
|
self.original = getattr(self, 'original', None)
|
||||||
|
|
||||||
if not self.original:
|
if not self.original:
|
||||||
|
@ -29,6 +29,7 @@ def stock_status_label(key, *args, **kwargs):
|
|||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def stock_status_text(key, *args, **kwargs):
|
def stock_status_text(key, *args, **kwargs):
|
||||||
|
"""Render the text value of a StockItem status value"""
|
||||||
return mark_safe(StockStatus.text(key))
|
return mark_safe(StockStatus.text(key))
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
"""Unit tests for the various part API endpoints"""
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
@ -11,7 +13,8 @@ from company.models import Company
|
|||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||||
StockStatus)
|
StockStatus)
|
||||||
from part.models import BomItem, BomItemSubstitute, Part, PartCategory
|
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
|
||||||
|
PartRelated)
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
|
|
||||||
@ -40,8 +43,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_category_list(self):
|
def test_category_list(self):
|
||||||
|
"""Test the PartCategoryList API endpoint"""
|
||||||
# List all part categories
|
|
||||||
url = reverse('api-part-category-list')
|
url = reverse('api-part-category-list')
|
||||||
|
|
||||||
response = self.get(url, expected_code=200)
|
response = self.get(url, expected_code=200)
|
||||||
@ -103,10 +105,6 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
|||||||
'part.add',
|
'part.add',
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
def test_part(self):
|
def test_part(self):
|
||||||
"""Test the Part API OPTIONS."""
|
"""Test the Part API OPTIONS."""
|
||||||
actions = self.getActions(reverse('api-part-list'))['POST']
|
actions = self.getActions(reverse('api-part-list'))['POST']
|
||||||
@ -207,21 +205,18 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
'part_category.add',
|
'part_category.add',
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
def test_get_categories(self):
|
def test_get_categories(self):
|
||||||
"""Test that we can retrieve list of part categories, with various filtering options."""
|
"""Test that we can retrieve list of part categories, with various filtering options."""
|
||||||
url = reverse('api-part-category-list')
|
url = reverse('api-part-category-list')
|
||||||
|
|
||||||
# Request *all* part categories
|
# Request *all* part categories
|
||||||
response = self.client.get(url, format='json')
|
response = self.get(url, format='json')
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 8)
|
self.assertEqual(len(response.data), 8)
|
||||||
|
|
||||||
# Request top-level part categories only
|
# Request top-level part categories only
|
||||||
response = self.client.get(
|
response = self.get(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'parent': 'null',
|
'parent': 'null',
|
||||||
@ -232,7 +227,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(response.data), 2)
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
# Children of PartCategory<1>, cascade
|
# Children of PartCategory<1>, cascade
|
||||||
response = self.client.get(
|
response = self.get(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'parent': 1,
|
'parent': 1,
|
||||||
@ -244,7 +239,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(response.data), 5)
|
self.assertEqual(len(response.data), 5)
|
||||||
|
|
||||||
# Children of PartCategory<1>, do not cascade
|
# Children of PartCategory<1>, do not cascade
|
||||||
response = self.client.get(
|
response = self.get(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'parent': 1,
|
'parent': 1,
|
||||||
@ -263,7 +258,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('api-part-category-list')
|
url = reverse('api-part-category-list')
|
||||||
response = self.client.post(url, data, format='json')
|
response = self.post(url, data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
parent = response.data['pk']
|
parent = response.data['pk']
|
||||||
@ -275,19 +270,20 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
'description': 'A sort of animal',
|
'description': 'A sort of animal',
|
||||||
'parent': parent,
|
'parent': parent,
|
||||||
}
|
}
|
||||||
response = self.client.post(url, data, format='json')
|
response = self.post(url, data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(response.data['parent'], parent)
|
self.assertEqual(response.data['parent'], parent)
|
||||||
self.assertEqual(response.data['name'], animal)
|
self.assertEqual(response.data['name'], animal)
|
||||||
self.assertEqual(response.data['pathstring'], 'Animals/' + animal)
|
self.assertEqual(response.data['pathstring'], 'Animals/' + animal)
|
||||||
|
|
||||||
# There should be now 8 categories
|
# There should be now 8 categories
|
||||||
response = self.client.get(url, format='json')
|
response = self.get(url, format='json')
|
||||||
self.assertEqual(len(response.data), 12)
|
self.assertEqual(len(response.data), 12)
|
||||||
|
|
||||||
def test_cat_detail(self):
|
def test_cat_detail(self):
|
||||||
|
"""Test the PartCategoryDetail API endpoint"""
|
||||||
url = reverse('api-part-category-detail', kwargs={'pk': 4})
|
url = reverse('api-part-category-detail', kwargs={'pk': 4})
|
||||||
response = self.client.get(url, format='json')
|
response = self.get(url, format='json')
|
||||||
|
|
||||||
# Test that we have retrieved the category
|
# Test that we have retrieved the category
|
||||||
self.assertEqual(response.data['description'], 'Integrated Circuits')
|
self.assertEqual(response.data['description'], 'Integrated Circuits')
|
||||||
@ -298,22 +294,22 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
data['name'] = 'Changing category'
|
data['name'] = 'Changing category'
|
||||||
data['parent'] = None
|
data['parent'] = None
|
||||||
data['description'] = 'Changing the description'
|
data['description'] = 'Changing the description'
|
||||||
response = self.client.patch(url, data, format='json')
|
response = self.patch(url, data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['description'], 'Changing the description')
|
self.assertEqual(response.data['description'], 'Changing the description')
|
||||||
self.assertIsNone(response.data['parent'])
|
self.assertIsNone(response.data['parent'])
|
||||||
|
|
||||||
def test_get_all_parts(self):
|
def test_filter_parts(self):
|
||||||
|
"""Test part filtering using the API"""
|
||||||
url = reverse('api-part-list')
|
url = reverse('api-part-list')
|
||||||
data = {'cascade': True}
|
data = {'cascade': True}
|
||||||
response = self.client.get(url, data, format='json')
|
response = self.get(url, data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), Part.objects.count())
|
self.assertEqual(len(response.data), Part.objects.count())
|
||||||
|
|
||||||
def test_get_parts_by_cat(self):
|
# Test filtering parts by category
|
||||||
url = reverse('api-part-list')
|
|
||||||
data = {'category': 2}
|
data = {'category': 2}
|
||||||
response = self.client.get(url, data, format='json')
|
response = self.get(url, data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
# There should only be 2 objects in category C
|
# There should only be 2 objects in category C
|
||||||
@ -322,6 +318,28 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
for part in response.data:
|
for part in response.data:
|
||||||
self.assertEqual(part['category'], 2)
|
self.assertEqual(part['category'], 2)
|
||||||
|
|
||||||
|
def test_filter_by_related(self):
|
||||||
|
"""Test that we can filter by the 'related' status"""
|
||||||
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
|
# Initially there are no relations, so this should return zero results
|
||||||
|
response = self.get(url, {'related': 1}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
|
# Add some relationships
|
||||||
|
PartRelated.objects.create(
|
||||||
|
part_1=Part.objects.get(pk=1),
|
||||||
|
part_2=Part.objects.get(pk=2),
|
||||||
|
)
|
||||||
|
|
||||||
|
PartRelated.objects.create(
|
||||||
|
part_2=Part.objects.get(pk=1),
|
||||||
|
part_1=Part.objects.get(pk=3)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.get(url, {'related': 1}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
def test_include_children(self):
|
def test_include_children(self):
|
||||||
"""Test the special 'include_child_categories' flag.
|
"""Test the special 'include_child_categories' flag.
|
||||||
|
|
||||||
@ -331,31 +349,31 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
data = {'category': 1, 'cascade': True}
|
data = {'category': 1, 'cascade': True}
|
||||||
|
|
||||||
# Now request to include child categories
|
# Now request to include child categories
|
||||||
response = self.client.get(url, data, format='json')
|
response = self.get(url, data, format='json')
|
||||||
|
|
||||||
# Now there should be 5 total parts
|
# Now there should be 5 total parts
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 3)
|
self.assertEqual(len(response.data), 3)
|
||||||
|
|
||||||
def test_test_templates(self):
|
def test_test_templates(self):
|
||||||
|
"""Test the PartTestTemplate API"""
|
||||||
url = reverse('api-part-test-template-list')
|
url = reverse('api-part-test-template-list')
|
||||||
|
|
||||||
# List ALL items
|
# List ALL items
|
||||||
response = self.client.get(url)
|
response = self.get(url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 7)
|
self.assertEqual(len(response.data), 7)
|
||||||
|
|
||||||
# Request for a particular part
|
# Request for a particular part
|
||||||
response = self.client.get(url, data={'part': 10000})
|
response = self.get(url, data={'part': 10000})
|
||||||
self.assertEqual(len(response.data), 5)
|
self.assertEqual(len(response.data), 5)
|
||||||
|
|
||||||
response = self.client.get(url, data={'part': 10004})
|
response = self.get(url, data={'part': 10004})
|
||||||
self.assertEqual(len(response.data), 7)
|
self.assertEqual(len(response.data), 7)
|
||||||
|
|
||||||
# Try to post a new object (missing description)
|
# Try to post a new object (missing description)
|
||||||
response = self.client.post(
|
response = self.post(
|
||||||
url,
|
url,
|
||||||
data={
|
data={
|
||||||
'part': 10000,
|
'part': 10000,
|
||||||
@ -367,7 +385,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
# Try to post a new object (should succeed)
|
# Try to post a new object (should succeed)
|
||||||
response = self.client.post(
|
response = self.post(
|
||||||
url,
|
url,
|
||||||
data={
|
data={
|
||||||
'part': 10000,
|
'part': 10000,
|
||||||
@ -381,7 +399,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
# Try to post a new test with the same name (should fail)
|
# Try to post a new test with the same name (should fail)
|
||||||
response = self.client.post(
|
response = self.post(
|
||||||
url,
|
url,
|
||||||
data={
|
data={
|
||||||
'part': 10004,
|
'part': 10004,
|
||||||
@ -394,7 +412,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Try to post a new test against a non-trackable part (should fail)
|
# Try to post a new test against a non-trackable part (should fail)
|
||||||
response = self.client.post(
|
response = self.post(
|
||||||
url,
|
url,
|
||||||
data={
|
data={
|
||||||
'part': 1,
|
'part': 1,
|
||||||
@ -408,7 +426,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
"""Return list of part thumbnails."""
|
"""Return list of part thumbnails."""
|
||||||
url = reverse('api-part-thumbs')
|
url = reverse('api-part-thumbs')
|
||||||
|
|
||||||
response = self.client.get(url)
|
response = self.get(url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
@ -432,7 +450,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
"""
|
"""
|
||||||
url = reverse('api-part-list')
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
response = self.client.post(url, {
|
response = self.post(url, {
|
||||||
'name': 'all defaults',
|
'name': 'all defaults',
|
||||||
'description': 'my test part',
|
'description': 'my test part',
|
||||||
'category': 1,
|
'category': 1,
|
||||||
@ -454,7 +472,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.user
|
self.user
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.post(url, {
|
response = self.post(url, {
|
||||||
'name': 'all defaults',
|
'name': 'all defaults',
|
||||||
'description': 'my test part 2',
|
'description': 'my test part 2',
|
||||||
'category': 1,
|
'category': 1,
|
||||||
@ -464,7 +482,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertTrue(response.data['purchaseable'])
|
self.assertTrue(response.data['purchaseable'])
|
||||||
|
|
||||||
# "default" values should not be used if the value is specified
|
# "default" values should not be used if the value is specified
|
||||||
response = self.client.post(url, {
|
response = self.post(url, {
|
||||||
'name': 'all defaults',
|
'name': 'all defaults',
|
||||||
'description': 'my test part 2',
|
'description': 'my test part 2',
|
||||||
'category': 1,
|
'category': 1,
|
||||||
@ -858,14 +876,12 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
'part_category.add',
|
'part_category.add',
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
def test_part_operations(self):
|
def test_part_operations(self):
|
||||||
|
"""Test that Part instances can be adjusted via the API"""
|
||||||
n = Part.objects.count()
|
n = Part.objects.count()
|
||||||
|
|
||||||
# Create a part
|
# Create a part
|
||||||
response = self.client.post(
|
response = self.post(
|
||||||
reverse('api-part-list'),
|
reverse('api-part-list'),
|
||||||
{
|
{
|
||||||
'name': 'my test api part',
|
'name': 'my test api part',
|
||||||
@ -890,7 +906,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
# Let's change the name of the part
|
# Let's change the name of the part
|
||||||
|
|
||||||
response = self.client.patch(url, {
|
response = self.patch(url, {
|
||||||
'name': 'a new better name',
|
'name': 'a new better name',
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -908,14 +924,14 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
# Now, try to set the name to the *same* value
|
# Now, try to set the name to the *same* value
|
||||||
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
|
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
|
||||||
response = self.client.patch(url, {
|
response = self.patch(url, {
|
||||||
'name': 'a new better name',
|
'name': 'a new better name',
|
||||||
})
|
})
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# Try to remove the part
|
# Try to remove the part
|
||||||
response = self.client.delete(url)
|
response = self.delete(url)
|
||||||
|
|
||||||
# As the part is 'active' we cannot delete it
|
# As the part is 'active' we cannot delete it
|
||||||
self.assertEqual(response.status_code, 405)
|
self.assertEqual(response.status_code, 405)
|
||||||
@ -923,7 +939,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
# So, let's make it not active
|
# So, let's make it not active
|
||||||
response = self.patch(url, {'active': False}, expected_code=200)
|
response = self.patch(url, {'active': False}, expected_code=200)
|
||||||
|
|
||||||
response = self.client.delete(url)
|
response = self.delete(url)
|
||||||
self.assertEqual(response.status_code, 204)
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
# Part count should have reduced
|
# Part count should have reduced
|
||||||
@ -932,7 +948,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
def test_duplicates(self):
|
def test_duplicates(self):
|
||||||
"""Check that trying to create 'duplicate' parts results in errors."""
|
"""Check that trying to create 'duplicate' parts results in errors."""
|
||||||
# Create a part
|
# Create a part
|
||||||
response = self.client.post(reverse('api-part-list'), {
|
response = self.post(reverse('api-part-list'), {
|
||||||
'name': 'part',
|
'name': 'part',
|
||||||
'description': 'description',
|
'description': 'description',
|
||||||
'IPN': 'IPN-123',
|
'IPN': 'IPN-123',
|
||||||
@ -945,7 +961,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
n = Part.objects.count()
|
n = Part.objects.count()
|
||||||
|
|
||||||
# Check that we cannot create a duplicate in a different category
|
# Check that we cannot create a duplicate in a different category
|
||||||
response = self.client.post(reverse('api-part-list'), {
|
response = self.post(reverse('api-part-list'), {
|
||||||
'name': 'part',
|
'name': 'part',
|
||||||
'description': 'description',
|
'description': 'description',
|
||||||
'IPN': 'IPN-123',
|
'IPN': 'IPN-123',
|
||||||
@ -968,7 +984,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(Part.objects.count(), n)
|
self.assertEqual(Part.objects.count(), n)
|
||||||
|
|
||||||
# But a different 'revision' *can* be created
|
# But a different 'revision' *can* be created
|
||||||
response = self.client.post(reverse('api-part-list'), {
|
response = self.post(reverse('api-part-list'), {
|
||||||
'name': 'part',
|
'name': 'part',
|
||||||
'description': 'description',
|
'description': 'description',
|
||||||
'IPN': 'IPN-123',
|
'IPN': 'IPN-123',
|
||||||
@ -985,7 +1001,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
url = reverse('api-part-detail', kwargs={'pk': pk})
|
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||||
|
|
||||||
# Attempt to alter the revision code
|
# Attempt to alter the revision code
|
||||||
response = self.client.patch(
|
response = self.patch(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'revision': 'A',
|
'revision': 'A',
|
||||||
@ -996,7 +1012,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
# But we *can* change it to a unique revision code
|
# But we *can* change it to a unique revision code
|
||||||
response = self.client.patch(
|
response = self.patch(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'revision': 'C',
|
'revision': 'C',
|
||||||
@ -1010,7 +1026,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
self.assignRole('part.add')
|
self.assignRole('part.add')
|
||||||
|
|
||||||
# Create a new part
|
# Create a new part
|
||||||
response = self.client.post(
|
response = self.post(
|
||||||
reverse('api-part-list'),
|
reverse('api-part-list'),
|
||||||
{
|
{
|
||||||
'name': 'imagine',
|
'name': 'imagine',
|
||||||
@ -1052,7 +1068,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
self.assertIn('Upload a valid image', str(response.data))
|
self.assertIn('Upload a valid image', str(response.data))
|
||||||
|
|
||||||
# Now try to upload a valid image file, in multiple formats
|
# Now try to upload a valid image file, in multiple formats
|
||||||
for fmt in ['jpg', 'png', 'bmp', 'webp']:
|
for fmt in ['jpg', 'j2k', 'png', 'bmp', 'webp']:
|
||||||
fn = f'dummy_image.{fmt}'
|
fn = f'dummy_image.{fmt}'
|
||||||
|
|
||||||
img = PIL.Image.new('RGB', (128, 128), color='red')
|
img = PIL.Image.new('RGB', (128, 128), color='red')
|
||||||
@ -1175,7 +1191,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
"""Create test data as part of setup routine"""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
# Ensure the part "variant" tree is correctly structured
|
# Ensure the part "variant" tree is correctly structured
|
||||||
@ -1199,9 +1215,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
|||||||
StockItem.objects.create(part=self.part, quantity=400, status=StockStatus.LOST)
|
StockItem.objects.create(part=self.part, quantity=400, status=StockStatus.LOST)
|
||||||
|
|
||||||
def get_part_data(self):
|
def get_part_data(self):
|
||||||
|
"""Helper function for retrieving part data"""
|
||||||
url = reverse('api-part-list')
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
response = self.client.get(url, format='json')
|
response = self.get(url, format='json')
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
@ -1397,9 +1414,6 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
'part.delete',
|
'part.delete',
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
def test_bom_list(self):
|
def test_bom_list(self):
|
||||||
"""Tests for the BomItem list endpoint."""
|
"""Tests for the BomItem list endpoint."""
|
||||||
# How many BOM items currently exist in the database?
|
# How many BOM items currently exist in the database?
|
||||||
@ -1518,7 +1532,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
# Now try to create a BomItem which references itself
|
# Now try to create a BomItem which references itself
|
||||||
data['part'] = 100
|
data['part'] = 100
|
||||||
data['sub_part'] = 100
|
data['sub_part'] = 100
|
||||||
self.client.post(url, data, expected_code=400)
|
self.post(url, data, expected_code=400)
|
||||||
|
|
||||||
def test_variants(self):
|
def test_variants(self):
|
||||||
"""Tests for BomItem use with variants."""
|
"""Tests for BomItem use with variants."""
|
||||||
@ -1781,20 +1795,16 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
'params',
|
'params',
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
def test_list_params(self):
|
def test_list_params(self):
|
||||||
"""Test for listing part parameters."""
|
"""Test for listing part parameters."""
|
||||||
url = reverse('api-part-parameter-list')
|
url = reverse('api-part-parameter-list')
|
||||||
|
|
||||||
response = self.client.get(url, format='json')
|
response = self.get(url, format='json')
|
||||||
|
|
||||||
self.assertEqual(len(response.data), 5)
|
self.assertEqual(len(response.data), 5)
|
||||||
|
|
||||||
# Filter by part
|
# Filter by part
|
||||||
response = self.client.get(
|
response = self.get(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'part': 3,
|
'part': 3,
|
||||||
@ -1805,7 +1815,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(response.data), 3)
|
self.assertEqual(len(response.data), 3)
|
||||||
|
|
||||||
# Filter by template
|
# Filter by template
|
||||||
response = self.client.get(
|
response = self.get(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'template': 1,
|
'template': 1,
|
||||||
@ -1819,7 +1829,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
"""Test that we can create a param via the API."""
|
"""Test that we can create a param via the API."""
|
||||||
url = reverse('api-part-parameter-list')
|
url = reverse('api-part-parameter-list')
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.post(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'part': '2',
|
'part': '2',
|
||||||
@ -1830,7 +1840,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
response = self.client.get(url, format='json')
|
response = self.get(url, format='json')
|
||||||
|
|
||||||
self.assertEqual(len(response.data), 6)
|
self.assertEqual(len(response.data), 6)
|
||||||
|
|
||||||
@ -1838,7 +1848,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
"""Tests for the PartParameter detail endpoint."""
|
"""Tests for the PartParameter detail endpoint."""
|
||||||
url = reverse('api-part-parameter-detail', kwargs={'pk': 5})
|
url = reverse('api-part-parameter-detail', kwargs={'pk': 5})
|
||||||
|
|
||||||
response = self.client.get(url)
|
response = self.get(url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -1849,12 +1859,12 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(data['data'], '12')
|
self.assertEqual(data['data'], '12')
|
||||||
|
|
||||||
# PATCH data back in
|
# PATCH data back in
|
||||||
response = self.client.patch(url, {'data': '15'}, format='json')
|
response = self.patch(url, {'data': '15'}, format='json')
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# Check that the data changed!
|
# Check that the data changed!
|
||||||
response = self.client.get(url, format='json')
|
response = self.get(url, format='json')
|
||||||
|
|
||||||
data = response.data
|
data = response.data
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ from InvenTree.helpers import InvenTreeTestCase
|
|||||||
|
|
||||||
|
|
||||||
class BomExportTest(InvenTreeTestCase):
|
class BomExportTest(InvenTreeTestCase):
|
||||||
|
"""Class for performing unit testing of BOM export functionality"""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
@ -19,6 +20,7 @@ class BomExportTest(InvenTreeTestCase):
|
|||||||
roles = 'all'
|
roles = 'all'
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
"""Perform test setup functions"""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.url = reverse('bom-download', kwargs={'pk': 100})
|
self.url = reverse('bom-download', kwargs={'pk': 100})
|
||||||
|
@ -18,6 +18,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
"""Create BOM data as part of setup routine"""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.part = Part.objects.create(
|
self.part = Part.objects.create(
|
||||||
@ -37,7 +38,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def post_bom(self, filename, file_data, clear_existing=None, expected_code=None, content_type='text/plain'):
|
def post_bom(self, filename, file_data, clear_existing=None, expected_code=None, content_type='text/plain'):
|
||||||
|
"""Helper function for submitting a BOM file"""
|
||||||
bom_file = SimpleUploadedFile(
|
bom_file = SimpleUploadedFile(
|
||||||
filename,
|
filename,
|
||||||
file_data,
|
file_data,
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
|
"""Unit tests for the BomItem model"""
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
@ -9,6 +11,7 @@ from .models import BomItem, BomItemSubstitute, Part
|
|||||||
|
|
||||||
|
|
||||||
class BomItemTest(TestCase):
|
class BomItemTest(TestCase):
|
||||||
|
"""Class for unit testing BomItem model"""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
@ -22,21 +25,25 @@ class BomItemTest(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
"""Create initial data"""
|
||||||
self.bob = Part.objects.get(id=100)
|
self.bob = Part.objects.get(id=100)
|
||||||
self.orphan = Part.objects.get(name='Orphan')
|
self.orphan = Part.objects.get(name='Orphan')
|
||||||
self.r1 = Part.objects.get(name='R_2K2_0805')
|
self.r1 = Part.objects.get(name='R_2K2_0805')
|
||||||
|
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
|
"""Test the string representation of a BOMItem"""
|
||||||
b = BomItem.objects.get(id=1)
|
b = BomItem.objects.get(id=1)
|
||||||
self.assertEqual(str(b), '10 x M2x4 LPHS to make BOB | Bob | A2')
|
self.assertEqual(str(b), '10 x M2x4 LPHS to make BOB | Bob | A2')
|
||||||
|
|
||||||
def test_has_bom(self):
|
def test_has_bom(self):
|
||||||
|
"""Test the has_bom attribute"""
|
||||||
self.assertFalse(self.orphan.has_bom)
|
self.assertFalse(self.orphan.has_bom)
|
||||||
self.assertTrue(self.bob.has_bom)
|
self.assertTrue(self.bob.has_bom)
|
||||||
|
|
||||||
self.assertEqual(self.bob.bom_count, 4)
|
self.assertEqual(self.bob.bom_count, 4)
|
||||||
|
|
||||||
def test_in_bom(self):
|
def test_in_bom(self):
|
||||||
|
"""Test BOM aggregation"""
|
||||||
parts = self.bob.getRequiredParts()
|
parts = self.bob.getRequiredParts()
|
||||||
|
|
||||||
self.assertIn(self.orphan, parts)
|
self.assertIn(self.orphan, parts)
|
||||||
@ -44,6 +51,7 @@ class BomItemTest(TestCase):
|
|||||||
self.assertTrue(self.bob.check_if_part_in_bom(self.orphan))
|
self.assertTrue(self.bob.check_if_part_in_bom(self.orphan))
|
||||||
|
|
||||||
def test_used_in(self):
|
def test_used_in(self):
|
||||||
|
"""Test that the 'used_in_count' attribute is calculated correctly"""
|
||||||
self.assertEqual(self.bob.used_in_count, 1)
|
self.assertEqual(self.bob.used_in_count, 1)
|
||||||
self.assertEqual(self.orphan.used_in_count, 1)
|
self.assertEqual(self.orphan.used_in_count, 1)
|
||||||
|
|
||||||
@ -116,6 +124,7 @@ class BomItemTest(TestCase):
|
|||||||
self.assertNotEqual(h1, h2)
|
self.assertNotEqual(h1, h2)
|
||||||
|
|
||||||
def test_pricing(self):
|
def test_pricing(self):
|
||||||
|
"""Test BOM pricing"""
|
||||||
self.bob.get_price(1)
|
self.bob.get_price(1)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.bob.get_bom_price_range(1, internal=True),
|
self.bob.get_bom_price_range(1, internal=True),
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
"""Unit tests for the PartCategory model"""
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
@ -18,7 +20,7 @@ class CategoryTest(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Extract some interesting categories for time-saving
|
"""Extract some interesting categories for time-saving"""
|
||||||
self.electronics = PartCategory.objects.get(name='Electronics')
|
self.electronics = PartCategory.objects.get(name='Electronics')
|
||||||
self.mechanical = PartCategory.objects.get(name='Mechanical')
|
self.mechanical = PartCategory.objects.get(name='Mechanical')
|
||||||
self.resistors = PartCategory.objects.get(name='Resistors')
|
self.resistors = PartCategory.objects.get(name='Resistors')
|
||||||
@ -111,8 +113,7 @@ class CategoryTest(TestCase):
|
|||||||
self.assertEqual(len(part_parameter), 1)
|
self.assertEqual(len(part_parameter), 1)
|
||||||
|
|
||||||
def test_invalid_name(self):
|
def test_invalid_name(self):
|
||||||
# Test that an illegal character is prohibited in a category name
|
"""Test that an illegal character is prohibited in a category name"""
|
||||||
|
|
||||||
cat = PartCategory(name='test/with/illegal/chars', description='Test category', parent=None)
|
cat = PartCategory(name='test/with/illegal/chars', description='Test category', parent=None)
|
||||||
|
|
||||||
with self.assertRaises(ValidationError) as err:
|
with self.assertRaises(ValidationError) as err:
|
||||||
|
@ -32,7 +32,7 @@ class TestForwardMigrations(MigratorTestCase):
|
|||||||
print(p.is_template)
|
print(p.is_template)
|
||||||
|
|
||||||
def test_models_exist(self):
|
def test_models_exist(self):
|
||||||
|
"""Test that the Part model can still be accessed at the end of schema migration"""
|
||||||
Part = self.new_state.apps.get_model('part', 'part')
|
Part = self.new_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
self.assertEqual(Part.objects.count(), 5)
|
self.assertEqual(Part.objects.count(), 5)
|
||||||
@ -42,3 +42,7 @@ class TestForwardMigrations(MigratorTestCase):
|
|||||||
part.save()
|
part.save()
|
||||||
part.is_template = False
|
part.is_template = False
|
||||||
part.save()
|
part.save()
|
||||||
|
|
||||||
|
for name in ['A', 'C', 'E']:
|
||||||
|
part = Part.objects.get(name=name)
|
||||||
|
self.assertEqual(part.description, f"My part {name}")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Tests for Part Parameters
|
"""Various unit tests for Part Parameters"""
|
||||||
|
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
from django.test import TestCase, TransactionTestCase
|
from django.test import TestCase, TransactionTestCase
|
||||||
@ -8,6 +8,7 @@ from .models import (Part, PartCategory, PartCategoryParameterTemplate,
|
|||||||
|
|
||||||
|
|
||||||
class TestParams(TestCase):
|
class TestParams(TestCase):
|
||||||
|
"""Unit test class for testing the PartParameter model"""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'location',
|
'location',
|
||||||
@ -17,7 +18,7 @@ class TestParams(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
|
"""Test the str representation of the PartParameterTemplate model"""
|
||||||
t1 = PartParameterTemplate.objects.get(pk=1)
|
t1 = PartParameterTemplate.objects.get(pk=1)
|
||||||
self.assertEqual(str(t1), 'Length (mm)')
|
self.assertEqual(str(t1), 'Length (mm)')
|
||||||
|
|
||||||
@ -28,7 +29,7 @@ class TestParams(TestCase):
|
|||||||
self.assertEqual(str(c1), 'Mechanical | Length | 2.8')
|
self.assertEqual(str(c1), 'Mechanical | Length | 2.8')
|
||||||
|
|
||||||
def test_validate(self):
|
def test_validate(self):
|
||||||
|
"""Test validation for part templates"""
|
||||||
n = PartParameterTemplate.objects.all().count()
|
n = PartParameterTemplate.objects.all().count()
|
||||||
|
|
||||||
t1 = PartParameterTemplate(name='abcde', units='dd')
|
t1 = PartParameterTemplate(name='abcde', units='dd')
|
||||||
@ -44,6 +45,7 @@ class TestParams(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestCategoryTemplates(TransactionTestCase):
|
class TestCategoryTemplates(TransactionTestCase):
|
||||||
|
"""Test class for PartCategoryParameterTemplate model"""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'location',
|
'location',
|
||||||
@ -53,7 +55,7 @@ class TestCategoryTemplates(TransactionTestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_validate(self):
|
def test_validate(self):
|
||||||
|
"""Test that category templates are correctly applied to Part instances"""
|
||||||
# Category templates
|
# Category templates
|
||||||
n = PartCategoryParameterTemplate.objects.all().count()
|
n = PartCategoryParameterTemplate.objects.all().count()
|
||||||
self.assertEqual(n, 2)
|
self.assertEqual(n, 2)
|
||||||
@ -79,6 +81,7 @@ class TestCategoryTemplates(TransactionTestCase):
|
|||||||
'main': True,
|
'main': True,
|
||||||
'parent': True,
|
'parent': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Save it with category parameters
|
# Save it with category parameters
|
||||||
part.save(**{'add_category_templates': add_category_templates})
|
part.save(**{'add_category_templates': add_category_templates})
|
||||||
|
|
||||||
|
@ -24,39 +24,42 @@ class TemplateTagTest(InvenTreeTestCase):
|
|||||||
"""Tests for the custom template tag code."""
|
"""Tests for the custom template tag code."""
|
||||||
|
|
||||||
def test_define(self):
|
def test_define(self):
|
||||||
|
"""Test the 'define' template tag"""
|
||||||
self.assertEqual(int(inventree_extras.define(3)), 3)
|
self.assertEqual(int(inventree_extras.define(3)), 3)
|
||||||
|
|
||||||
def test_str2bool(self):
|
def test_str2bool(self):
|
||||||
|
"""Various test for the str2bool template tag"""
|
||||||
self.assertEqual(int(inventree_extras.str2bool('true')), True)
|
self.assertEqual(int(inventree_extras.str2bool('true')), True)
|
||||||
self.assertEqual(int(inventree_extras.str2bool('yes')), True)
|
self.assertEqual(int(inventree_extras.str2bool('yes')), True)
|
||||||
self.assertEqual(int(inventree_extras.str2bool('none')), False)
|
self.assertEqual(int(inventree_extras.str2bool('none')), False)
|
||||||
self.assertEqual(int(inventree_extras.str2bool('off')), False)
|
self.assertEqual(int(inventree_extras.str2bool('off')), False)
|
||||||
|
|
||||||
def test_inrange(self):
|
|
||||||
self.assertEqual(inventree_extras.inrange(3), range(3))
|
|
||||||
|
|
||||||
def test_multiply(self):
|
|
||||||
self.assertEqual(int(inventree_extras.multiply(3, 5)), 15)
|
|
||||||
|
|
||||||
def test_add(self):
|
def test_add(self):
|
||||||
|
"""Test that the 'add"""
|
||||||
self.assertEqual(int(inventree_extras.add(3, 5)), 8)
|
self.assertEqual(int(inventree_extras.add(3, 5)), 8)
|
||||||
|
|
||||||
def test_plugins_enabled(self):
|
def test_plugins_enabled(self):
|
||||||
|
"""Test the plugins_enabled tag"""
|
||||||
self.assertEqual(inventree_extras.plugins_enabled(), True)
|
self.assertEqual(inventree_extras.plugins_enabled(), True)
|
||||||
|
|
||||||
def test_inventree_instance_name(self):
|
def test_inventree_instance_name(self):
|
||||||
|
"""Test the 'instance name' setting"""
|
||||||
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree server')
|
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree server')
|
||||||
|
|
||||||
def test_inventree_base_url(self):
|
def test_inventree_base_url(self):
|
||||||
|
"""Test that the base URL tag returns correctly"""
|
||||||
self.assertEqual(inventree_extras.inventree_base_url(), '')
|
self.assertEqual(inventree_extras.inventree_base_url(), '')
|
||||||
|
|
||||||
def test_inventree_is_release(self):
|
def test_inventree_is_release(self):
|
||||||
|
"""Test that the release version check functions as expected"""
|
||||||
self.assertEqual(inventree_extras.inventree_is_release(), not version.isInvenTreeDevelopmentVersion())
|
self.assertEqual(inventree_extras.inventree_is_release(), not version.isInvenTreeDevelopmentVersion())
|
||||||
|
|
||||||
def test_inventree_docs_version(self):
|
def test_inventree_docs_version(self):
|
||||||
|
"""Test that the documentation version template tag returns correctly"""
|
||||||
self.assertEqual(inventree_extras.inventree_docs_version(), version.inventreeDocsVersion())
|
self.assertEqual(inventree_extras.inventree_docs_version(), version.inventreeDocsVersion())
|
||||||
|
|
||||||
def test_hash(self):
|
def test_hash(self):
|
||||||
|
"""Test that the commit hash template tag returns correctly"""
|
||||||
result_hash = inventree_extras.inventree_commit_hash()
|
result_hash = inventree_extras.inventree_commit_hash()
|
||||||
if settings.DOCKER: # pragma: no cover
|
if settings.DOCKER: # pragma: no cover
|
||||||
# Testing inside docker environment *may* return an empty git commit hash
|
# Testing inside docker environment *may* return an empty git commit hash
|
||||||
@ -66,6 +69,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
|||||||
self.assertGreater(len(result_hash), 5)
|
self.assertGreater(len(result_hash), 5)
|
||||||
|
|
||||||
def test_date(self):
|
def test_date(self):
|
||||||
|
"""Test that the commit date template tag returns correctly"""
|
||||||
d = inventree_extras.inventree_commit_date()
|
d = inventree_extras.inventree_commit_date()
|
||||||
if settings.DOCKER: # pragma: no cover
|
if settings.DOCKER: # pragma: no cover
|
||||||
# Testing inside docker environment *may* return an empty git commit hash
|
# Testing inside docker environment *may* return an empty git commit hash
|
||||||
@ -75,26 +79,33 @@ class TemplateTagTest(InvenTreeTestCase):
|
|||||||
self.assertEqual(len(d.split('-')), 3)
|
self.assertEqual(len(d.split('-')), 3)
|
||||||
|
|
||||||
def test_github(self):
|
def test_github(self):
|
||||||
|
"""Test that the github URL template tag returns correctly"""
|
||||||
self.assertIn('github.com', inventree_extras.inventree_github_url())
|
self.assertIn('github.com', inventree_extras.inventree_github_url())
|
||||||
|
|
||||||
def test_docs(self):
|
def test_docs(self):
|
||||||
|
"""Test that the documentation URL template tag returns correctly"""
|
||||||
self.assertIn('inventree.readthedocs.io', inventree_extras.inventree_docs_url())
|
self.assertIn('inventree.readthedocs.io', inventree_extras.inventree_docs_url())
|
||||||
|
|
||||||
def test_keyvalue(self):
|
def test_keyvalue(self):
|
||||||
|
"""Test keyvalue template tag"""
|
||||||
self.assertEqual(inventree_extras.keyvalue({'a': 'a'}, 'a'), 'a')
|
self.assertEqual(inventree_extras.keyvalue({'a': 'a'}, 'a'), 'a')
|
||||||
|
|
||||||
def test_mail_configured(self):
|
def test_mail_configured(self):
|
||||||
|
"""Test that mail configuration returns False"""
|
||||||
self.assertEqual(inventree_extras.mail_configured(), False)
|
self.assertEqual(inventree_extras.mail_configured(), False)
|
||||||
|
|
||||||
def test_user_settings(self):
|
def test_user_settings(self):
|
||||||
|
"""Test user settings"""
|
||||||
result = inventree_extras.user_settings(self.user)
|
result = inventree_extras.user_settings(self.user)
|
||||||
self.assertEqual(len(result), len(InvenTreeUserSetting.SETTINGS))
|
self.assertEqual(len(result), len(InvenTreeUserSetting.SETTINGS))
|
||||||
|
|
||||||
def test_global_settings(self):
|
def test_global_settings(self):
|
||||||
|
"""Test global settings"""
|
||||||
result = inventree_extras.global_settings()
|
result = inventree_extras.global_settings()
|
||||||
self.assertEqual(len(result), len(InvenTreeSetting.SETTINGS))
|
self.assertEqual(len(result), len(InvenTreeSetting.SETTINGS))
|
||||||
|
|
||||||
def test_visible_global_settings(self):
|
def test_visible_global_settings(self):
|
||||||
|
"""Test that hidden global settings are actually hidden"""
|
||||||
result = inventree_extras.visible_global_settings()
|
result = inventree_extras.visible_global_settings()
|
||||||
|
|
||||||
n = len(result)
|
n = len(result)
|
||||||
@ -122,6 +133,9 @@ class PartTest(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
"""Create some Part instances as part of init routine"""
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
self.r1 = Part.objects.get(name='R_2K2_0805')
|
self.r1 = Part.objects.get(name='R_2K2_0805')
|
||||||
self.r2 = Part.objects.get(name='R_4K7_0603')
|
self.r2 = Part.objects.get(name='R_4K7_0603')
|
||||||
|
|
||||||
@ -130,7 +144,7 @@ class PartTest(TestCase):
|
|||||||
Part.objects.rebuild()
|
Part.objects.rebuild()
|
||||||
|
|
||||||
def test_tree(self):
|
def test_tree(self):
|
||||||
# Test that the part variant tree is working properly
|
"""Test that the part variant tree is working properly"""
|
||||||
chair = Part.objects.get(pk=10000)
|
chair = Part.objects.get(pk=10000)
|
||||||
self.assertEqual(chair.get_children().count(), 3)
|
self.assertEqual(chair.get_children().count(), 3)
|
||||||
self.assertEqual(chair.get_descendant_count(), 4)
|
self.assertEqual(chair.get_descendant_count(), 4)
|
||||||
@ -142,6 +156,7 @@ class PartTest(TestCase):
|
|||||||
self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5)
|
self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5)
|
||||||
|
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
|
"""Test string representation of a Part"""
|
||||||
p = Part.objects.get(pk=100)
|
p = Part.objects.get(pk=100)
|
||||||
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
||||||
|
|
||||||
@ -198,10 +213,12 @@ class PartTest(TestCase):
|
|||||||
part_2.validate_unique()
|
part_2.validate_unique()
|
||||||
|
|
||||||
def test_attributes(self):
|
def test_attributes(self):
|
||||||
|
"""Test Part attributes"""
|
||||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||||
|
|
||||||
def test_category(self):
|
def test_category(self):
|
||||||
|
"""Test PartCategory path"""
|
||||||
self.assertEqual(str(self.c1.category), 'Electronics/Capacitors - Capacitors')
|
self.assertEqual(str(self.c1.category), 'Electronics/Capacitors - Capacitors')
|
||||||
|
|
||||||
orphan = Part.objects.get(name='Orphan')
|
orphan = Part.objects.get(name='Orphan')
|
||||||
@ -209,26 +226,29 @@ class PartTest(TestCase):
|
|||||||
self.assertEqual(orphan.category_path, '')
|
self.assertEqual(orphan.category_path, '')
|
||||||
|
|
||||||
def test_rename_img(self):
|
def test_rename_img(self):
|
||||||
|
"""Test that an image can be renamed"""
|
||||||
img = rename_part_image(self.r1, 'hello.png')
|
img = rename_part_image(self.r1, 'hello.png')
|
||||||
self.assertEqual(img, os.path.join('part_images', 'hello.png'))
|
self.assertEqual(img, os.path.join('part_images', 'hello.png'))
|
||||||
|
|
||||||
def test_stock(self):
|
def test_stock(self):
|
||||||
# No stock of any resistors
|
"""Test case where there is zero stock"""
|
||||||
res = Part.objects.filter(description__contains='resistor')
|
res = Part.objects.filter(description__contains='resistor')
|
||||||
for r in res:
|
for r in res:
|
||||||
self.assertEqual(r.total_stock, 0)
|
self.assertEqual(r.total_stock, 0)
|
||||||
self.assertEqual(r.available_stock, 0)
|
self.assertEqual(r.available_stock, 0)
|
||||||
|
|
||||||
def test_barcode(self):
|
def test_barcode(self):
|
||||||
|
"""Test barcode format functionality"""
|
||||||
barcode = self.r1.format_barcode(brief=False)
|
barcode = self.r1.format_barcode(brief=False)
|
||||||
self.assertIn('InvenTree', barcode)
|
self.assertIn('InvenTree', barcode)
|
||||||
self.assertIn(self.r1.name, barcode)
|
self.assertIn(self.r1.name, barcode)
|
||||||
|
|
||||||
def test_copy(self):
|
def test_copy(self):
|
||||||
|
"""Test that we can 'deep copy' a Part instance"""
|
||||||
self.r2.deep_copy(self.r1, image=True, bom=True)
|
self.r2.deep_copy(self.r1, image=True, bom=True)
|
||||||
|
|
||||||
def test_sell_pricing(self):
|
def test_sell_pricing(self):
|
||||||
# check that the sell pricebreaks were loaded
|
"""Check that the sell pricebreaks were loaded"""
|
||||||
self.assertTrue(self.r1.has_price_breaks)
|
self.assertTrue(self.r1.has_price_breaks)
|
||||||
self.assertEqual(self.r1.price_breaks.count(), 2)
|
self.assertEqual(self.r1.price_breaks.count(), 2)
|
||||||
# check that the sell pricebreaks work
|
# check that the sell pricebreaks work
|
||||||
@ -236,7 +256,7 @@ class PartTest(TestCase):
|
|||||||
self.assertEqual(float(self.r1.get_price(10)), 1.0)
|
self.assertEqual(float(self.r1.get_price(10)), 1.0)
|
||||||
|
|
||||||
def test_internal_pricing(self):
|
def test_internal_pricing(self):
|
||||||
# check that the sell pricebreaks were loaded
|
"""Check that the sell pricebreaks were loaded"""
|
||||||
self.assertTrue(self.r1.has_internal_price_breaks)
|
self.assertTrue(self.r1.has_internal_price_breaks)
|
||||||
self.assertEqual(self.r1.internal_price_breaks.count(), 2)
|
self.assertEqual(self.r1.internal_price_breaks.count(), 2)
|
||||||
# check that the sell pricebreaks work
|
# check that the sell pricebreaks work
|
||||||
@ -262,6 +282,7 @@ class PartTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestTemplateTest(TestCase):
|
class TestTemplateTest(TestCase):
|
||||||
|
"""Unit test for the TestTemplate class"""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
@ -271,7 +292,7 @@ class TestTemplateTest(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_template_count(self):
|
def test_template_count(self):
|
||||||
|
"""Tests for the test template functions"""
|
||||||
chair = Part.objects.get(pk=10000)
|
chair = Part.objects.get(pk=10000)
|
||||||
|
|
||||||
# Tests for the top-level chair object (nothing above it!)
|
# Tests for the top-level chair object (nothing above it!)
|
||||||
@ -288,8 +309,7 @@ class TestTemplateTest(TestCase):
|
|||||||
self.assertEqual(variant.getTestTemplates(required=True).count(), 5)
|
self.assertEqual(variant.getTestTemplates(required=True).count(), 5)
|
||||||
|
|
||||||
def test_uniqueness(self):
|
def test_uniqueness(self):
|
||||||
# Test names must be unique for this part and also parts above
|
"""Test names must be unique for this part and also parts above"""
|
||||||
|
|
||||||
variant = Part.objects.get(pk=10004)
|
variant = Part.objects.get(pk=10004)
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
@ -424,6 +444,7 @@ class PartSettingsTest(InvenTreeTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class PartSubscriptionTests(InvenTreeTestCase):
|
class PartSubscriptionTests(InvenTreeTestCase):
|
||||||
|
"""Unit tests for part 'subscription'"""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'location',
|
'location',
|
||||||
@ -432,6 +453,7 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
"""Create category and part data as part of setup routine"""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
# electronics / IC / MCU
|
# electronics / IC / MCU
|
||||||
@ -531,6 +553,7 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
"""Add an email address as part of initialization"""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
# Add Mailadress
|
# Add Mailadress
|
||||||
EmailAddress.objects.create(user=self.user, email='test@testing.com')
|
EmailAddress.objects.create(user=self.user, email='test@testing.com')
|
||||||
@ -568,6 +591,7 @@ class PartNotificationTest(BaseNotificationIntegrationTest):
|
|||||||
"""Integration test for part notifications."""
|
"""Integration test for part notifications."""
|
||||||
|
|
||||||
def test_notification(self):
|
def test_notification(self):
|
||||||
|
"""Test that a notification is generated"""
|
||||||
self._notification_run(UIMessageNotification)
|
self._notification_run(UIMessageNotification)
|
||||||
|
|
||||||
# There should be 1 notification message right now
|
# There should be 1 notification message right now
|
||||||
|
@ -8,6 +8,7 @@ from .models import Part
|
|||||||
|
|
||||||
|
|
||||||
class PartViewTestCase(InvenTreeTestCase):
|
class PartViewTestCase(InvenTreeTestCase):
|
||||||
|
"""Base class for unit testing the various Part views"""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
@ -21,13 +22,12 @@ class PartViewTestCase(InvenTreeTestCase):
|
|||||||
roles = 'all'
|
roles = 'all'
|
||||||
superuser = True
|
superuser = True
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
|
|
||||||
class PartListTest(PartViewTestCase):
|
class PartListTest(PartViewTestCase):
|
||||||
|
"""Unit tests for the PartList view"""
|
||||||
|
|
||||||
def test_part_index(self):
|
def test_part_index(self):
|
||||||
|
"""Test that the PartIndex page returns successfully"""
|
||||||
response = self.client.get(reverse('part-index'))
|
response = self.client.get(reverse('part-index'))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -38,6 +38,7 @@ class PartListTest(PartViewTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class PartDetailTest(PartViewTestCase):
|
class PartDetailTest(PartViewTestCase):
|
||||||
|
"""Unit tests for the PartDetail view"""
|
||||||
|
|
||||||
def test_part_detail(self):
|
def test_part_detail(self):
|
||||||
"""Test that we can retrieve a part detail page."""
|
"""Test that we can retrieve a part detail page."""
|
||||||
@ -67,6 +68,7 @@ class PartDetailTest(PartViewTestCase):
|
|||||||
pk = 1
|
pk = 1
|
||||||
|
|
||||||
def test_ipn_match(index_result=False, detail_result=False):
|
def test_ipn_match(index_result=False, detail_result=False):
|
||||||
|
"""Helper function for matching IPN detail view"""
|
||||||
index_redirect = False
|
index_redirect = False
|
||||||
detail_redirect = False
|
detail_redirect = False
|
||||||
|
|
||||||
@ -117,11 +119,12 @@ class PartQRTest(PartViewTestCase):
|
|||||||
"""Tests for the Part QR Code AJAX view."""
|
"""Tests for the Part QR Code AJAX view."""
|
||||||
|
|
||||||
def test_html_redirect(self):
|
def test_html_redirect(self):
|
||||||
# A HTML request for a QR code should be redirected (use an AJAX request instead)
|
"""A HTML request for a QR code should be redirected (use an AJAX request instead)"""
|
||||||
response = self.client.get(reverse('part-qr', args=(1,)))
|
response = self.client.get(reverse('part-qr', args=(1,)))
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def test_valid_part(self):
|
def test_valid_part(self):
|
||||||
|
"""Test QR code response for a Part"""
|
||||||
response = self.client.get(reverse('part-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.get(reverse('part-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -131,6 +134,7 @@ class PartQRTest(PartViewTestCase):
|
|||||||
self.assertIn('<img src=', data)
|
self.assertIn('<img src=', data)
|
||||||
|
|
||||||
def test_invalid_part(self):
|
def test_invalid_part(self):
|
||||||
|
"""Test response for an invalid Part ID value"""
|
||||||
response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -48,10 +48,16 @@ class PartIndex(InvenTreeRoleMixin, ListView):
|
|||||||
context_object_name = 'parts'
|
context_object_name = 'parts'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
"""Custom queryset lookup to prefetch related fields"""
|
||||||
return Part.objects.all().select_related('category')
|
return Part.objects.all().select_related('category')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Returns custom context data for the PartIndex view:
|
||||||
|
|
||||||
|
- children: Number of child categories
|
||||||
|
- category_count: Number of child categories
|
||||||
|
- part_count: Number of parts contained
|
||||||
|
"""
|
||||||
context = super().get_context_data(**kwargs).copy()
|
context = super().get_context_data(**kwargs).copy()
|
||||||
|
|
||||||
# View top-level categories
|
# View top-level categories
|
||||||
@ -120,15 +126,13 @@ class PartSetCategory(AjaxUpdateView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if valid:
|
if valid:
|
||||||
self.set_category()
|
with transaction.atomic():
|
||||||
|
for part in self.parts:
|
||||||
|
part.category = self.category
|
||||||
|
part.save()
|
||||||
|
|
||||||
return self.renderJsonResponse(request, data=data, form=self.get_form(), context=self.get_context_data())
|
return self.renderJsonResponse(request, data=data, form=self.get_form(), context=self.get_context_data())
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def set_category(self):
|
|
||||||
for part in self.parts:
|
|
||||||
part.set_category(self.category)
|
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
"""Return context data for rendering in the form."""
|
"""Return context data for rendering in the form."""
|
||||||
ctx = {}
|
ctx = {}
|
||||||
@ -145,6 +149,7 @@ class PartImport(FileManagementFormView):
|
|||||||
permission_required = 'part.add'
|
permission_required = 'part.add'
|
||||||
|
|
||||||
class PartFileManager(FileManager):
|
class PartFileManager(FileManager):
|
||||||
|
"""Import field definitions"""
|
||||||
REQUIRED_HEADERS = [
|
REQUIRED_HEADERS = [
|
||||||
'Name',
|
'Name',
|
||||||
'Description',
|
'Description',
|
||||||
@ -338,6 +343,7 @@ class PartImport(FileManagementFormView):
|
|||||||
|
|
||||||
|
|
||||||
class PartImportAjax(FileManagementAjaxView, PartImport):
|
class PartImportAjax(FileManagementAjaxView, PartImport):
|
||||||
|
"""Multi-step form wizard for importing Part data"""
|
||||||
ajax_form_steps_template = [
|
ajax_form_steps_template = [
|
||||||
'part/import_wizard/ajax_part_upload.html',
|
'part/import_wizard/ajax_part_upload.html',
|
||||||
'part/import_wizard/ajax_match_fields.html',
|
'part/import_wizard/ajax_match_fields.html',
|
||||||
@ -345,6 +351,7 @@ class PartImportAjax(FileManagementAjaxView, PartImport):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, obj, form, **kwargs):
|
def validate(self, obj, form, **kwargs):
|
||||||
|
"""Validation is performed based on the current form step"""
|
||||||
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@ -385,6 +392,7 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
|||||||
return Decimal(self.request.POST.get('quantity', 1))
|
return Decimal(self.request.POST.get('quantity', 1))
|
||||||
|
|
||||||
def get_part(self):
|
def get_part(self):
|
||||||
|
"""Return the Part instance associated with this view"""
|
||||||
return self.get_object()
|
return self.get_object()
|
||||||
|
|
||||||
def get_pricing(self, quantity=1, currency=None):
|
def get_pricing(self, quantity=1, currency=None):
|
||||||
@ -499,6 +507,7 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
|||||||
return {'quantity': self.get_quantity()}
|
return {'quantity': self.get_quantity()}
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""POST action performs as a GET action"""
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
kwargs['object'] = self.object
|
kwargs['object'] = self.object
|
||||||
ctx = self.get_context_data(**kwargs)
|
ctx = self.get_context_data(**kwargs)
|
||||||
@ -506,6 +515,8 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
|||||||
|
|
||||||
|
|
||||||
class PartDetailFromIPN(PartDetail):
|
class PartDetailFromIPN(PartDetail):
|
||||||
|
"""Part detail view using the IPN (internal part number) of the Part as the lookup field"""
|
||||||
|
|
||||||
slug_field = 'IPN'
|
slug_field = 'IPN'
|
||||||
slug_url_kwarg = 'slug'
|
slug_url_kwarg = 'slug'
|
||||||
|
|
||||||
@ -646,7 +657,7 @@ class PartImageSelect(AjaxUpdateView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Perform POST action to assign selected image to the Part instance"""
|
||||||
part = self.get_object()
|
part = self.get_object()
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
|
|
||||||
@ -688,7 +699,7 @@ class BomUploadTemplate(AjaxView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Perform a GET request to download the 'BOM upload' template"""
|
||||||
export_format = request.GET.get('format', 'csv')
|
export_format = request.GET.get('format', 'csv')
|
||||||
|
|
||||||
return MakeBomTemplate(export_format)
|
return MakeBomTemplate(export_format)
|
||||||
@ -705,7 +716,7 @@ class BomDownload(AjaxView):
|
|||||||
model = Part
|
model = Part
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Perform GET request to download BOM data"""
|
||||||
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
||||||
|
|
||||||
export_format = request.GET.get('format', 'csv')
|
export_format = request.GET.get('format', 'csv')
|
||||||
@ -746,6 +757,7 @@ class BomDownload(AjaxView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
|
"""Return a cutsom message"""
|
||||||
return {
|
return {
|
||||||
'info': 'Exported BOM'
|
'info': 'Exported BOM'
|
||||||
}
|
}
|
||||||
@ -762,6 +774,7 @@ class PartDelete(AjaxDeleteView):
|
|||||||
success_url = '/part/'
|
success_url = '/part/'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
|
"""Returns custom message once the part deletion has been performed"""
|
||||||
return {
|
return {
|
||||||
'danger': _('Part was deleted'),
|
'danger': _('Part was deleted'),
|
||||||
}
|
}
|
||||||
@ -782,6 +795,7 @@ class PartPricing(AjaxView):
|
|||||||
return Decimal(self.request.POST.get('quantity', 1))
|
return Decimal(self.request.POST.get('quantity', 1))
|
||||||
|
|
||||||
def get_part(self):
|
def get_part(self):
|
||||||
|
"""Return the Part instance associated with this view"""
|
||||||
try:
|
try:
|
||||||
return Part.objects.get(id=self.kwargs['pk'])
|
return Part.objects.get(id=self.kwargs['pk'])
|
||||||
except Part.DoesNotExist:
|
except Part.DoesNotExist:
|
||||||
@ -886,12 +900,14 @@ class PartPricing(AjaxView):
|
|||||||
return {'quantity': self.get_quantity()}
|
return {'quantity': self.get_quantity()}
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Perform custom GET action for this view"""
|
||||||
init = self.get_initials()
|
init = self.get_initials()
|
||||||
qty = self.get_quantity()
|
qty = self.get_quantity()
|
||||||
|
|
||||||
return self.renderJsonResponse(request, self.form_class(initial=init), context=self.get_pricing(qty))
|
return self.renderJsonResponse(request, self.form_class(initial=init), context=self.get_pricing(qty))
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Perform custom POST action for this view"""
|
||||||
currency = None
|
currency = None
|
||||||
|
|
||||||
quantity = self.get_quantity()
|
quantity = self.get_quantity()
|
||||||
@ -946,7 +962,12 @@ class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
|||||||
template_name = 'part/category.html'
|
template_name = 'part/category.html'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Returns custom context data for the CategoryDetail view:
|
||||||
|
|
||||||
|
- part_count: Number of parts in this category
|
||||||
|
- starred_directly: True if this category is starred directly by the requesting user
|
||||||
|
- starred: True if this category is starred by the requesting user
|
||||||
|
"""
|
||||||
context = super().get_context_data(**kwargs).copy()
|
context = super().get_context_data(**kwargs).copy()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -960,18 +981,22 @@ class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
|||||||
if category:
|
if category:
|
||||||
|
|
||||||
# Insert "starred" information
|
# Insert "starred" information
|
||||||
context['starred'] = category.is_starred_by(self.request.user)
|
|
||||||
context['starred_directly'] = context['starred'] and category.is_starred_by(
|
context['starred_directly'] = context['starred'] and category.is_starred_by(
|
||||||
self.request.user,
|
self.request.user,
|
||||||
include_parents=False,
|
include_parents=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if context['starred_directly']:
|
||||||
|
# Save a database lookup - if 'starred_directly' is True, we know 'starred' is also
|
||||||
|
context['starred'] = True
|
||||||
|
else:
|
||||||
|
context['starred'] = category.is_starred_by(self.request.user)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class CategoryDelete(AjaxDeleteView):
|
class CategoryDelete(AjaxDeleteView):
|
||||||
"""Delete view to delete a PartCategory."""
|
"""Delete view to delete a PartCategory."""
|
||||||
|
|
||||||
model = PartCategory
|
model = PartCategory
|
||||||
ajax_template_name = 'part/category_delete.html'
|
ajax_template_name = 'part/category_delete.html'
|
||||||
ajax_form_title = _('Delete Part Category')
|
ajax_form_title = _('Delete Part Category')
|
||||||
@ -979,6 +1004,7 @@ class CategoryDelete(AjaxDeleteView):
|
|||||||
success_url = '/part/'
|
success_url = '/part/'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
|
"""Return custom context data when the category is deleted"""
|
||||||
return {
|
return {
|
||||||
'danger': _('Part category was deleted'),
|
'danger': _('Part category was deleted'),
|
||||||
}
|
}
|
||||||
@ -1092,6 +1118,11 @@ class CategoryParameterTemplateEdit(AjaxUpdateView):
|
|||||||
ajax_form_title = _('Edit Category Parameter Template')
|
ajax_form_title = _('Edit Category Parameter Template')
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
|
"""Returns the PartCategoryParameterTemplate associated with this view
|
||||||
|
|
||||||
|
- First, attempt lookup based on supplied 'pid' kwarg
|
||||||
|
- Else, attempt lookup based on supplied 'pk' kwarg
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
||||||
except:
|
except:
|
||||||
@ -1148,6 +1179,11 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
|
|||||||
ajax_form_title = _("Delete Category Parameter Template")
|
ajax_form_title = _("Delete Category Parameter Template")
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
|
"""Returns the PartCategoryParameterTemplate associated with this view
|
||||||
|
|
||||||
|
- First, attempt lookup based on supplied 'pid' kwarg
|
||||||
|
- Else, attempt lookup based on supplied 'pk' kwarg
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
||||||
except:
|
except:
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
"""Functions to print a label to a mixin printer."""
|
"""Functions to print a label to a mixin printer"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.debug import ExceptionReporter
|
||||||
|
|
||||||
|
import pdf2image
|
||||||
|
from error_report.models import Error
|
||||||
|
|
||||||
import common.notifications
|
import common.notifications
|
||||||
from plugin.registry import registry
|
from plugin.registry import registry
|
||||||
@ -10,19 +16,21 @@ from plugin.registry import registry
|
|||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
def print_label(plugin_slug: str, label_image, label_instance=None, user=None):
|
def print_label(plugin_slug, pdf_data, filename=None, label_instance=None, user=None):
|
||||||
"""Print label with the provided plugin.
|
"""
|
||||||
|
Print label with the provided plugin.
|
||||||
|
|
||||||
This task is nominally handled by the background worker.
|
This task is nominally handled by the background worker.
|
||||||
|
|
||||||
If the printing fails (throws an exception) then the user is notified.
|
If the printing fails (throws an exception) then the user is notified.
|
||||||
|
|
||||||
Args:
|
Arguments:
|
||||||
plugin_slug (str): The unique slug (key) of the plugin
|
plugin_slug: The unique slug (key) of the plugin
|
||||||
label_image (_type_): A PIL.Image image object to be printed
|
pdf_data: Binary PDF data
|
||||||
label_instance (Union[LabelTemplate, None], optional): The template instance that should be printed. Defaults to None.
|
filename: The intended name of the printed label
|
||||||
user (Union[User, None], optional): User that should be informed of errors. Defaults to None.
|
|
||||||
"""
|
"""
|
||||||
logger.info(f"Plugin '{plugin_slug}' is printing a label")
|
|
||||||
|
logger.info(f"Plugin '{plugin_slug}' is printing a label '{filename}'")
|
||||||
|
|
||||||
plugin = registry.plugins.get(plugin_slug, None)
|
plugin = registry.plugins.get(plugin_slug, None)
|
||||||
|
|
||||||
@ -30,8 +38,22 @@ def print_label(plugin_slug: str, label_image, label_instance=None, user=None):
|
|||||||
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
|
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# In addition to providing a .pdf image, we'll also provide a .png file
|
||||||
|
png_file = pdf2image.convert_from_bytes(
|
||||||
|
pdf_data,
|
||||||
|
dpi=300,
|
||||||
|
)[0]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height)
|
plugin.print_label(
|
||||||
|
pdf_data=pdf_data,
|
||||||
|
png_file=png_file,
|
||||||
|
filename=filename,
|
||||||
|
label_instance=label_instance,
|
||||||
|
width=label_instance.width,
|
||||||
|
height=label_instance.height,
|
||||||
|
user=user
|
||||||
|
)
|
||||||
except Exception as e: # pragma: no cover
|
except Exception as e: # pragma: no cover
|
||||||
# Plugin threw an error - notify the user who attempted to print
|
# Plugin threw an error - notify the user who attempted to print
|
||||||
|
|
||||||
@ -40,13 +62,28 @@ def print_label(plugin_slug: str, label_image, label_instance=None, user=None):
|
|||||||
'message': str(e),
|
'message': str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(f"Label printing failed: Sending notification to user '{user}'")
|
# Log an error message to the database
|
||||||
|
kind, info, data = sys.exc_info()
|
||||||
|
|
||||||
|
Error.objects.create(
|
||||||
|
kind=kind.__name__,
|
||||||
|
info=info,
|
||||||
|
data='\n'.join(traceback.format_exception(kind, info, data)),
|
||||||
|
path='print_label',
|
||||||
|
html=ExceptionReporter(None, kind, info, data).get_traceback_html(),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.error(f"Label printing failed: Sending notification to user '{user}'") # pragma: no cover
|
||||||
|
|
||||||
# Throw an error against the plugin instance
|
# Throw an error against the plugin instance
|
||||||
common.notifications.trigger_notifaction(
|
common.notifications.trigger_notification(
|
||||||
plugin.plugin_config(),
|
plugin.plugin_config(),
|
||||||
'label.printing_failed',
|
'label.printing_failed',
|
||||||
targets=[user],
|
targets=[user],
|
||||||
context=ctx,
|
context=ctx,
|
||||||
delivery_methods=[common.notifications.UIMessageNotification]
|
delivery_methods=set([common.notifications.UIMessageNotification])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.TESTING:
|
||||||
|
# If we are in testing mode, we want to know about this exception
|
||||||
|
raise e
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
"""Plugin mixin classes for label plugins."""
|
"""Plugin mixin classes for label plugins"""
|
||||||
|
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class LabelPrintingMixin:
|
class LabelPrintingMixin:
|
||||||
"""Mixin which enables direct printing of stock labels.
|
"""
|
||||||
|
Mixin which enables direct printing of stock labels.
|
||||||
|
|
||||||
Each plugin must provide a NAME attribute, which is used to uniquely identify the printer.
|
Each plugin must provide a NAME attribute, which is used to uniquely identify the printer.
|
||||||
|
|
||||||
@ -12,8 +13,9 @@ class LabelPrintingMixin:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
class MixinMeta:
|
class MixinMeta:
|
||||||
"""Meta options for this mixin."""
|
"""
|
||||||
|
Meta options for this mixin
|
||||||
|
"""
|
||||||
MIXIN_NAME = 'Label printing'
|
MIXIN_NAME = 'Label printing'
|
||||||
|
|
||||||
def __init__(self): # pragma: no cover
|
def __init__(self): # pragma: no cover
|
||||||
@ -21,15 +23,19 @@ class LabelPrintingMixin:
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('labels', True, __class__)
|
self.add_mixin('labels', True, __class__)
|
||||||
|
|
||||||
def print_label(self, label, **kwargs):
|
def print_label(self, **kwargs):
|
||||||
"""Callback to print a single label.
|
"""
|
||||||
|
Callback to print a single label
|
||||||
Arguments:
|
|
||||||
label: A black-and-white pillow Image object
|
|
||||||
|
|
||||||
kwargs:
|
kwargs:
|
||||||
length: The length of the label (in mm)
|
pdf_data: Raw PDF data of the rendered label
|
||||||
width: The width of the label (in mm)
|
png_file: An in-memory PIL image file, rendered at 300dpi
|
||||||
|
label_instance: The instance of the label model which triggered the print_label() method
|
||||||
|
width: The expected width of the label (in mm)
|
||||||
|
height: The expected height of the label (in mm)
|
||||||
|
filename: The filename of this PDF label
|
||||||
|
user: The user who printed this label
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Unimplemented (to be implemented by the particular plugin class)
|
# Unimplemented (to be implemented by the particular plugin class)
|
||||||
raise MixinNotImplementedError('This Plugin must implement a `print_label` method')
|
raise MixinNotImplementedError('This Plugin must implement a `print_label` method')
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
"""Unit tests for the label printing mixin."""
|
"""Unit tests for the label printing mixin"""
|
||||||
|
import os
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
from label.models import PartLabel, StockItemLabel, StockLocationLabel
|
from label.models import PartLabel, StockItemLabel, StockLocationLabel
|
||||||
@ -15,7 +18,7 @@ from stock.models import StockItem, StockLocation
|
|||||||
|
|
||||||
|
|
||||||
class LabelMixinTests(InvenTreeAPITestCase):
|
class LabelMixinTests(InvenTreeAPITestCase):
|
||||||
"""Test that the Label mixin operates correctly."""
|
"""Test that the Label mixin operates correctly"""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
@ -27,13 +30,14 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
roles = 'all'
|
roles = 'all'
|
||||||
|
|
||||||
def do_activate_plugin(self):
|
def do_activate_plugin(self):
|
||||||
"""Activate the 'samplelabel' plugin."""
|
"""Activate the 'samplelabel' plugin"""
|
||||||
|
|
||||||
config = registry.get_plugin('samplelabel').plugin_config()
|
config = registry.get_plugin('samplelabel').plugin_config()
|
||||||
config.active = True
|
config.active = True
|
||||||
config.save()
|
config.save()
|
||||||
|
|
||||||
def do_url(self, parts, plugin_ref, label, url_name: str = 'api-part-label-print', url_single: str = 'part', invalid: bool = False):
|
def do_url(self, parts, plugin_ref, label, url_name: str = 'api-part-label-print', url_single: str = 'part', invalid: bool = False):
|
||||||
"""Generate an URL to print a label."""
|
"""Generate an URL to print a label"""
|
||||||
# Construct URL
|
# Construct URL
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if label:
|
if label:
|
||||||
@ -60,16 +64,18 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
def test_wrong_implementation(self):
|
def test_wrong_implementation(self):
|
||||||
"""Test that a wrong implementation raises an error."""
|
"""Test that a wrong implementation raises an error"""
|
||||||
|
|
||||||
class WrongPlugin(LabelPrintingMixin, InvenTreePlugin):
|
class WrongPlugin(LabelPrintingMixin, InvenTreePlugin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
with self.assertRaises(MixinNotImplementedError):
|
with self.assertRaises(MixinNotImplementedError):
|
||||||
plugin = WrongPlugin()
|
plugin = WrongPlugin()
|
||||||
plugin.print_label('test')
|
plugin.print_label(filename='test')
|
||||||
|
|
||||||
def test_installed(self):
|
def test_installed(self):
|
||||||
"""Test that the sample printing plugin is installed."""
|
"""Test that the sample printing plugin is installed"""
|
||||||
|
|
||||||
# Get all label plugins
|
# Get all label plugins
|
||||||
plugins = registry.with_mixin('labels')
|
plugins = registry.with_mixin('labels')
|
||||||
self.assertEqual(len(plugins), 1)
|
self.assertEqual(len(plugins), 1)
|
||||||
@ -79,7 +85,8 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(plugins), 0)
|
self.assertEqual(len(plugins), 0)
|
||||||
|
|
||||||
def test_api(self):
|
def test_api(self):
|
||||||
"""Test that we can filter the API endpoint by mixin."""
|
"""Test that we can filter the API endpoint by mixin"""
|
||||||
|
|
||||||
url = reverse('api-plugin-list')
|
url = reverse('api-plugin-list')
|
||||||
|
|
||||||
# Try POST (disallowed)
|
# Try POST (disallowed)
|
||||||
@ -123,7 +130,8 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(data['key'], 'samplelabel')
|
self.assertEqual(data['key'], 'samplelabel')
|
||||||
|
|
||||||
def test_printing_process(self):
|
def test_printing_process(self):
|
||||||
"""Test that a label can be printed."""
|
"""Test that a label can be printed"""
|
||||||
|
|
||||||
# Ensure the labels were created
|
# Ensure the labels were created
|
||||||
apps.get_app_config('label').create_labels()
|
apps.get_app_config('label').create_labels()
|
||||||
|
|
||||||
@ -162,6 +170,21 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
# Print no part
|
# Print no part
|
||||||
self.get(self.do_url(None, plugin_ref, label), expected_code=400)
|
self.get(self.do_url(None, plugin_ref, label), expected_code=400)
|
||||||
|
|
||||||
|
# Test that the labels have been printed
|
||||||
|
# The sample labelling plugin simply prints to file
|
||||||
|
self.assertTrue(os.path.exists('label.pdf'))
|
||||||
|
|
||||||
|
# Read the raw .pdf data - ensure it contains some sensible information
|
||||||
|
with open('label.pdf', 'rb') as f:
|
||||||
|
pdf_data = str(f.read())
|
||||||
|
self.assertIn('WeasyPrint', pdf_data)
|
||||||
|
|
||||||
|
# Check that the .png file has already been created
|
||||||
|
self.assertTrue(os.path.exists('label.png'))
|
||||||
|
|
||||||
|
# And that it is a valid image file
|
||||||
|
Image.open('label.png')
|
||||||
|
|
||||||
def test_printing_endpoints(self):
|
def test_printing_endpoints(self):
|
||||||
"""Cover the endpoints not covered by `test_printing_process`."""
|
"""Cover the endpoints not covered by `test_printing_process`."""
|
||||||
plugin_ref = 'samplelabel'
|
plugin_ref = 'samplelabel'
|
||||||
@ -171,7 +194,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
self.do_activate_plugin()
|
self.do_activate_plugin()
|
||||||
|
|
||||||
def run_print_test(label, qs, url_name, url_single):
|
def run_print_test(label, qs, url_name, url_single):
|
||||||
"""Run tests on single and multiple page printing.
|
"""Run tests on single and multiple page printing
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
label (_type_): class of the label
|
label (_type_): class of the label
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
"""Registry for loading and managing multiple plugins at run-time.
|
"""
|
||||||
|
Registry for loading and managing multiple plugins at run-time
|
||||||
|
|
||||||
- Holds the class and the object that contains all code to maintain plugin states
|
- Holds the class and the object that contains all code to maintain plugin states
|
||||||
- Manages setup and teardown of plugin class instances
|
- Manages setup and teardown of plugin class instances
|
||||||
@ -30,7 +31,9 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
class PluginsRegistry:
|
class PluginsRegistry:
|
||||||
"""The PluginsRegistry class."""
|
"""
|
||||||
|
The PluginsRegistry class
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize registry.
|
"""Initialize registry.
|
||||||
@ -56,7 +59,10 @@ class PluginsRegistry:
|
|||||||
self.mixins_settings = {}
|
self.mixins_settings = {}
|
||||||
|
|
||||||
def get_plugin(self, slug):
|
def get_plugin(self, slug):
|
||||||
"""Lookup plugin by slug (unique key)."""
|
"""
|
||||||
|
Lookup plugin by slug (unique key).
|
||||||
|
"""
|
||||||
|
|
||||||
if slug not in self.plugins:
|
if slug not in self.plugins:
|
||||||
logger.warning(f"Plugin registry has no record of plugin '{slug}'")
|
logger.warning(f"Plugin registry has no record of plugin '{slug}'")
|
||||||
return None
|
return None
|
||||||
@ -64,13 +70,15 @@ class PluginsRegistry:
|
|||||||
return self.plugins[slug]
|
return self.plugins[slug]
|
||||||
|
|
||||||
def call_plugin_function(self, slug, func, *args, **kwargs):
|
def call_plugin_function(self, slug, func, *args, **kwargs):
|
||||||
"""Call a member function (named by 'func') of the plugin named by 'slug'.
|
"""
|
||||||
|
Call a member function (named by 'func') of the plugin named by 'slug'.
|
||||||
|
|
||||||
As this is intended to be run by the background worker,
|
As this is intended to be run by the background worker,
|
||||||
we do not perform any try/except here.
|
we do not perform any try/except here.
|
||||||
|
|
||||||
Instead, any error messages are returned to the worker.
|
Instead, any error messages are returned to the worker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
plugin = self.get_plugin(slug)
|
plugin = self.get_plugin(slug)
|
||||||
|
|
||||||
if not plugin:
|
if not plugin:
|
||||||
@ -82,8 +90,12 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
# region public functions
|
# region public functions
|
||||||
# region loading / unloading
|
# region loading / unloading
|
||||||
def load_plugins(self):
|
def load_plugins(self, full_reload: bool = False):
|
||||||
"""Load and activate all IntegrationPlugins."""
|
"""Load and activate all IntegrationPlugins
|
||||||
|
|
||||||
|
Args:
|
||||||
|
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
|
||||||
|
"""
|
||||||
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
|
||||||
@ -103,7 +115,7 @@ class PluginsRegistry:
|
|||||||
try:
|
try:
|
||||||
# We are using the db so for migrations etc we need to try this block
|
# We are using the db so for migrations etc we need to try this block
|
||||||
self._init_plugins(blocked_plugin)
|
self._init_plugins(blocked_plugin)
|
||||||
self._activate_plugins()
|
self._activate_plugins(full_reload=full_reload)
|
||||||
registered_successful = True
|
registered_successful = True
|
||||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||||
# Exception if the database has not been migrated yet
|
# Exception if the database has not been migrated yet
|
||||||
@ -117,7 +129,7 @@ class PluginsRegistry:
|
|||||||
# Initialize apps without any plugins
|
# Initialize apps without any plugins
|
||||||
self._clean_registry()
|
self._clean_registry()
|
||||||
self._clean_installed_apps()
|
self._clean_installed_apps()
|
||||||
self._activate_plugins(force_reload=True)
|
self._activate_plugins(force_reload=True, full_reload=full_reload)
|
||||||
|
|
||||||
# We do not want to end in an endless loop
|
# We do not want to end in an endless loop
|
||||||
retry_counter -= 1
|
retry_counter -= 1
|
||||||
@ -131,6 +143,10 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
# now the loading will re-start up with init
|
# now the loading will re-start up with init
|
||||||
|
|
||||||
|
# disable full reload after the first round
|
||||||
|
if full_reload:
|
||||||
|
full_reload = False
|
||||||
|
|
||||||
# Remove maintenance mode
|
# Remove maintenance mode
|
||||||
if not _maintenance:
|
if not _maintenance:
|
||||||
set_maintenance_mode(False)
|
set_maintenance_mode(False)
|
||||||
@ -138,7 +154,10 @@ class PluginsRegistry:
|
|||||||
logger.info('Finished loading plugins')
|
logger.info('Finished loading plugins')
|
||||||
|
|
||||||
def unload_plugins(self):
|
def unload_plugins(self):
|
||||||
"""Unload and deactivate all IntegrationPlugins."""
|
"""
|
||||||
|
Unload and deactivate all IntegrationPlugins
|
||||||
|
"""
|
||||||
|
|
||||||
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
|
||||||
@ -161,8 +180,13 @@ class PluginsRegistry:
|
|||||||
set_maintenance_mode(False) # pragma: no cover
|
set_maintenance_mode(False) # pragma: no cover
|
||||||
logger.info('Finished unloading plugins')
|
logger.info('Finished unloading plugins')
|
||||||
|
|
||||||
def reload_plugins(self):
|
def reload_plugins(self, full_reload: bool = False):
|
||||||
"""Safely reload IntegrationPlugins."""
|
"""Safely reload IntegrationPlugins
|
||||||
|
|
||||||
|
Args:
|
||||||
|
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
|
||||||
|
"""
|
||||||
|
|
||||||
# Do not reload whe currently loading
|
# Do not reload whe currently loading
|
||||||
if self.is_loading:
|
if self.is_loading:
|
||||||
return # pragma: no cover
|
return # pragma: no cover
|
||||||
@ -171,12 +195,13 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
with maintenance_mode_on():
|
with maintenance_mode_on():
|
||||||
self.unload_plugins()
|
self.unload_plugins()
|
||||||
self.load_plugins()
|
self.load_plugins(full_reload)
|
||||||
|
|
||||||
logger.info('Finished reloading plugins')
|
logger.info('Finished reloading plugins')
|
||||||
|
|
||||||
def collect_plugins(self):
|
def collect_plugins(self):
|
||||||
"""Collect plugins from all possible ways of loading."""
|
"""Collect plugins from all possible ways of loading"""
|
||||||
|
|
||||||
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
|
||||||
@ -205,7 +230,10 @@ class PluginsRegistry:
|
|||||||
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
|
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
|
||||||
|
|
||||||
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
|
||||||
|
"""
|
||||||
|
|
||||||
if settings.PLUGIN_FILE_CHECKED:
|
if settings.PLUGIN_FILE_CHECKED:
|
||||||
logger.info('Plugin file was already checked')
|
logger.info('Plugin file was already checked')
|
||||||
return True
|
return True
|
||||||
@ -226,7 +254,9 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
# region registry functions
|
# region registry functions
|
||||||
def with_mixin(self, mixin: str, active=None):
|
def with_mixin(self, mixin: str, active=None):
|
||||||
"""Returns reference to all plugins that have a specified mixin enabled."""
|
"""
|
||||||
|
Returns reference to all plugins that have a specified mixin enabled
|
||||||
|
"""
|
||||||
result = []
|
result = []
|
||||||
|
|
||||||
for plugin in self.plugins.values():
|
for plugin in self.plugins.values():
|
||||||
@ -247,12 +277,14 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
# 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=None):
|
||||||
"""Initialise all found plugins.
|
"""
|
||||||
|
Initialise all found plugins
|
||||||
|
|
||||||
:param disabled: loading path of disabled app, defaults to None
|
:param disabled: loading path of disabled app, defaults to None
|
||||||
:type disabled: str, optional
|
:type disabled: str, optional
|
||||||
:raises error: IntegrationPluginError
|
:raises error: IntegrationPluginError
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from plugin.models import PluginConfig
|
from plugin.models import PluginConfig
|
||||||
|
|
||||||
logger.info('Starting plugin initialisation')
|
logger.info('Starting plugin initialisation')
|
||||||
@ -315,11 +347,12 @@ class PluginsRegistry:
|
|||||||
# save for later reference
|
# save for later reference
|
||||||
self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover
|
self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover
|
||||||
|
|
||||||
def _activate_plugins(self, force_reload=False):
|
def _activate_plugins(self, force_reload=False, full_reload: bool = False):
|
||||||
"""Run activation functions for all plugins.
|
"""Run activation functions for all plugins
|
||||||
|
|
||||||
:param force_reload: force reload base apps, defaults to False
|
Args:
|
||||||
:type force_reload: bool, optional
|
force_reload (bool, optional): Also reload base apps. Defaults to False.
|
||||||
|
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
|
||||||
"""
|
"""
|
||||||
# activate integrations
|
# activate integrations
|
||||||
plugins = self.plugins.items()
|
plugins = self.plugins.items()
|
||||||
@ -327,10 +360,11 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
self.activate_plugin_settings(plugins)
|
self.activate_plugin_settings(plugins)
|
||||||
self.activate_plugin_schedule(plugins)
|
self.activate_plugin_schedule(plugins)
|
||||||
self.activate_plugin_app(plugins, force_reload=force_reload)
|
self.activate_plugin_app(plugins, force_reload=force_reload, full_reload=full_reload)
|
||||||
|
|
||||||
def _deactivate_plugins(self):
|
def _deactivate_plugins(self):
|
||||||
"""Run deactivation functions for all plugins."""
|
"""Run deactivation functions for all plugins"""
|
||||||
|
|
||||||
self.deactivate_plugin_app()
|
self.deactivate_plugin_app()
|
||||||
self.deactivate_plugin_schedule()
|
self.deactivate_plugin_schedule()
|
||||||
self.deactivate_plugin_settings()
|
self.deactivate_plugin_settings()
|
||||||
@ -403,20 +437,21 @@ class PluginsRegistry:
|
|||||||
logger.warning("activate_integration_schedule failed, database not ready")
|
logger.warning("activate_integration_schedule failed, database not ready")
|
||||||
|
|
||||||
def deactivate_plugin_schedule(self):
|
def deactivate_plugin_schedule(self):
|
||||||
"""Deactivate ScheduleMixin.
|
"""
|
||||||
|
Deactivate ScheduleMixin
|
||||||
currently nothing is done
|
currently nothing is done
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def activate_plugin_app(self, plugins, force_reload=False):
|
def activate_plugin_app(self, plugins, force_reload=False, full_reload: bool = False):
|
||||||
"""Activate AppMixin plugins - add custom apps and reload.
|
"""Activate AppMixin plugins - add custom apps and reload
|
||||||
|
|
||||||
:param plugins: list of IntegrationPlugins that should be installed
|
Args:
|
||||||
:type plugins: dict
|
plugins (dict): List of IntegrationPlugins that should be installed
|
||||||
:param force_reload: only reload base apps, defaults to False
|
force_reload (bool, optional): Only reload base apps. Defaults to False.
|
||||||
:type force_reload: bool, optional
|
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
|
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
|
||||||
@ -437,9 +472,9 @@ class PluginsRegistry:
|
|||||||
# first startup or force loading of base apps -> registry is prob false
|
# first startup or force loading of base apps -> registry is prob false
|
||||||
if self.apps_loading or force_reload:
|
if self.apps_loading or force_reload:
|
||||||
self.apps_loading = False
|
self.apps_loading = False
|
||||||
self._reload_apps(force_reload=True)
|
self._reload_apps(force_reload=True, full_reload=full_reload)
|
||||||
else:
|
else:
|
||||||
self._reload_apps()
|
self._reload_apps(full_reload=full_reload)
|
||||||
|
|
||||||
# rediscover models/ admin sites
|
# rediscover models/ admin sites
|
||||||
self._reregister_contrib_apps()
|
self._reregister_contrib_apps()
|
||||||
@ -481,9 +516,8 @@ class PluginsRegistry:
|
|||||||
reload(app_config.module.admin)
|
reload(app_config.module.admin)
|
||||||
|
|
||||||
def _get_plugin_path(self, plugin):
|
def _get_plugin_path(self, plugin):
|
||||||
"""Parse plugin path.
|
"""parse plugin path
|
||||||
|
the input can be eiter:
|
||||||
The input can be eiter:
|
|
||||||
- a local file / dir
|
- a local file / dir
|
||||||
- a package
|
- a package
|
||||||
"""
|
"""
|
||||||
@ -566,8 +600,17 @@ class PluginsRegistry:
|
|||||||
global_pattern[0] = re_path('', include(urlpatterns))
|
global_pattern[0] = re_path('', include(urlpatterns))
|
||||||
clear_url_caches()
|
clear_url_caches()
|
||||||
|
|
||||||
def _reload_apps(self, force_reload: bool = False):
|
def _reload_apps(self, force_reload: bool = False, full_reload: bool = False):
|
||||||
self.is_loading = True # set flag to disable loop reloading
|
"""Internal: reload apps using django internal functions
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_reload (bool, optional): Also reload base apps. Defaults to False.
|
||||||
|
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# If full_reloading is set to true we do not want to set the flag
|
||||||
|
if not full_reload:
|
||||||
|
self.is_loading = True # set flag to disable loop reloading
|
||||||
if force_reload:
|
if force_reload:
|
||||||
# we can not use the built in functions as we need to brute force the registry
|
# we can not use the built in functions as we need to brute force the registry
|
||||||
apps.app_configs = OrderedDict()
|
apps.app_configs = OrderedDict()
|
||||||
@ -579,9 +622,9 @@ class PluginsRegistry:
|
|||||||
self.is_loading = False
|
self.is_loading = False
|
||||||
|
|
||||||
def _try_reload(self, cmd, *args, **kwargs):
|
def _try_reload(self, cmd, *args, **kwargs):
|
||||||
"""Wrapper to try reloading the apps.
|
"""
|
||||||
|
wrapper to try reloading the apps
|
||||||
Throws an custom error that gets handled by the loading function
|
throws an custom error that gets handled by the loading function
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd(*args, **kwargs)
|
cmd(*args, **kwargs)
|
||||||
@ -595,5 +638,5 @@ registry = PluginsRegistry()
|
|||||||
|
|
||||||
|
|
||||||
def call_function(plugin_name, function_name, *args, **kwargs):
|
def call_function(plugin_name, function_name, *args, **kwargs):
|
||||||
"""Global helper function to call a specific member function of a plugin."""
|
""" Global helper function to call a specific member function of a plugin """
|
||||||
return registry.call_plugin_function(plugin_name, function_name, *args, **kwargs)
|
return registry.call_plugin_function(plugin_name, function_name, *args, **kwargs)
|
||||||
|
@ -14,11 +14,25 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
|||||||
SLUG = "samplelabel"
|
SLUG = "samplelabel"
|
||||||
TITLE = "Sample Label Printer"
|
TITLE = "Sample Label Printer"
|
||||||
DESCRIPTION = "A sample plugin which provides a (fake) label printer interface"
|
DESCRIPTION = "A sample plugin which provides a (fake) label printer interface"
|
||||||
VERSION = "0.1"
|
VERSION = "0.2"
|
||||||
|
|
||||||
def print_label(self, label, **kwargs):
|
def print_label(self, **kwargs):
|
||||||
"""Sample printing step.
|
"""Sample printing step.
|
||||||
|
|
||||||
Normally here the connection to the printer and transfer of the label would take place.
|
Normally here the connection to the printer and transfer of the label would take place.
|
||||||
"""
|
"""
|
||||||
print("OK PRINTING")
|
# Test that the expected kwargs are present
|
||||||
|
print(f"Printing Label: {kwargs['filename']} (User: {kwargs['user']})")
|
||||||
|
print(f"Width: {kwargs['width']} x Height: {kwargs['height']}")
|
||||||
|
|
||||||
|
pdf_data = kwargs['pdf_data']
|
||||||
|
png_file = kwargs['png_file']
|
||||||
|
|
||||||
|
filename = kwargs['filename']
|
||||||
|
|
||||||
|
# Dump the PDF to a local file
|
||||||
|
with open(filename, 'wb') as pdf_out:
|
||||||
|
pdf_out.write(pdf_data)
|
||||||
|
|
||||||
|
# Save the PNG to disk
|
||||||
|
png_file.save(filename.replace('.pdf', '.png'))
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<!-- Badges -->
|
<!-- Badges -->
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
[](https://coveralls.io/github/inventree/InvenTree)
|
[](https://coveralls.io/github/inventree/InvenTree)
|
||||||
[](https://crowdin.com/project/inventree)
|
[](https://crowdin.com/project/inventree)
|
||||||
|
@ -1,8 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
On release, ensure that the release tag matches the InvenTree version number!
|
Ensure that the release tag matches the InvenTree version number:
|
||||||
|
|
||||||
|
master / main branch:
|
||||||
|
- version number must end with 'dev'
|
||||||
|
|
||||||
|
stable branch:
|
||||||
|
- version number must *not* end with 'dev'
|
||||||
|
- version number cannot already exist as a release tag
|
||||||
|
|
||||||
|
tagged branch:
|
||||||
|
- version number must match tag being built
|
||||||
|
- version number cannot already exist as a release tag
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
@ -11,6 +22,14 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
here = os.path.abspath(os.path.dirname(__file__))
|
here = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
# GITHUB_REF_TYPE may be either 'branch' or 'tag'
|
||||||
|
GITHUB_REF_TYPE = os.environ['GITHUB_REF_TYPE']
|
||||||
|
|
||||||
|
# GITHUB_REF may be either 'refs/heads/<branch>' or 'refs/heads/<tag>'
|
||||||
|
GITHUB_REF = os.environ['GITHUB_REF']
|
||||||
|
|
||||||
|
GITHUB_BASE_REF = os.environ['GITHUB_BASE_REF']
|
||||||
|
|
||||||
version_file = os.path.join(here, '..', 'InvenTree', 'InvenTree', 'version.py')
|
version_file = os.path.join(here, '..', 'InvenTree', 'InvenTree', 'version.py')
|
||||||
|
|
||||||
version = None
|
version = None
|
||||||
@ -30,66 +49,66 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
print(f"InvenTree Version: '{version}'")
|
print(f"InvenTree Version: '{version}'")
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
# Determine which docker tag we are going to use
|
||||||
parser.add_argument('-t', '--tag', help='Compare against specified version tag', action='store')
|
docker_tag = None
|
||||||
parser.add_argument('-r', '--release', help='Check that this is a release version', action='store_true')
|
|
||||||
parser.add_argument('-d', '--dev', help='Check that this is a development version', action='store_true')
|
|
||||||
parser.add_argument('-b', '--branch', help='Check against a particular branch', action='store')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
if GITHUB_REF_TYPE == 'branch' and ('stable' in GITHUB_REF or 'stable' in GITHUB_BASE_REF):
|
||||||
|
print("Checking requirements for 'stable' release branch:")
|
||||||
if args.branch:
|
|
||||||
"""
|
|
||||||
Version number requirement depends on format of branch
|
|
||||||
|
|
||||||
'master': development branch
|
|
||||||
'stable': release branch
|
|
||||||
"""
|
|
||||||
|
|
||||||
print(f"Checking version number for branch '{args.branch}'")
|
|
||||||
|
|
||||||
if args.branch == 'master':
|
|
||||||
print("- This is a development branch")
|
|
||||||
args.dev = True
|
|
||||||
elif args.branch == 'stable':
|
|
||||||
print("- This is a stable release branch")
|
|
||||||
args.release = True
|
|
||||||
|
|
||||||
if args.dev:
|
|
||||||
"""
|
|
||||||
Check that the current verrsion number matches the "development" format
|
|
||||||
e.g. "0.5 dev"
|
|
||||||
"""
|
|
||||||
|
|
||||||
print("Checking development branch")
|
|
||||||
|
|
||||||
pattern = r"^\d+(\.\d+)+ dev$"
|
|
||||||
|
|
||||||
result = re.match(pattern, version)
|
|
||||||
|
|
||||||
if result is None:
|
|
||||||
print(f"Version number '{version}' does not match required pattern for development branch")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
elif args.release:
|
|
||||||
"""
|
|
||||||
Check that the current version number matches the "release" format
|
|
||||||
e.g. "0.5.1"
|
|
||||||
"""
|
|
||||||
|
|
||||||
print("Checking release branch")
|
|
||||||
|
|
||||||
pattern = r"^\d+(\.\d+)+$"
|
pattern = r"^\d+(\.\d+)+$"
|
||||||
|
|
||||||
result = re.match(pattern, version)
|
result = re.match(pattern, version)
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
print(f"Version number '{version}' does not match required pattern for stable branch")
|
print(f"Version number '{version}' does not match required pattern for stable branch")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f"Version number '{version}' matches stable branch")
|
||||||
|
|
||||||
if args.tag:
|
docker_tag = 'stable'
|
||||||
if args.tag != version:
|
|
||||||
print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'")
|
elif GITHUB_REF_TYPE == 'tag':
|
||||||
|
# GITHUB_REF should be of th eform /refs/heads/<tag>
|
||||||
|
version_tag = GITHUB_REF.split('/')[-1]
|
||||||
|
print(f"Checking requirements for tagged release - '{version_tag}':")
|
||||||
|
|
||||||
|
if version_tag != version:
|
||||||
|
print(f"Version number '{version}' does not match tag '{version_tag}'")
|
||||||
|
sys.exit
|
||||||
|
|
||||||
|
# TODO: Check if there is already a release with this tag!
|
||||||
|
|
||||||
|
docker_tag = version_tag
|
||||||
|
|
||||||
|
elif GITHUB_REF_TYPE == 'branch':
|
||||||
|
# Otherwise we know we are targetting the 'master' branch
|
||||||
|
print("Checking requirements for 'master' development branch:")
|
||||||
|
|
||||||
|
pattern = r"^\d+(\.\d+)+ dev$"
|
||||||
|
result = re.match(pattern, version)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
print(f"Version number '{version}' does not match required pattern for development branch")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f"Version number '{version}' matches development branch")
|
||||||
|
|
||||||
sys.exit(0)
|
docker_tag = 'latest'
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Unsupported branch / version combination:")
|
||||||
|
print(f"InvenTree Version: {version}")
|
||||||
|
print("GITHUB_REF_TYPE:", GITHUB_REF_TYPE)
|
||||||
|
print("GITHUB_BASE_REF:", GITHUB_BASE_REF)
|
||||||
|
print("GITHUB_REF:", GITHUB_REF)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if docker_tag is None:
|
||||||
|
print("Docker tag could not be determined")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Version check passed for '{version}'!")
|
||||||
|
print(f"Docker tag: '{docker_tag}'")
|
||||||
|
|
||||||
|
# Ref: https://getridbug.com/python/how-to-set-environment-variables-in-github-actions-using-python/
|
||||||
|
with open(os.getenv('GITHUB_ENV'), 'a') as env_file:
|
||||||
|
env_file.write(f"docker_tag={docker_tag}\n")
|
||||||
|
@ -101,4 +101,4 @@ volumes:
|
|||||||
o: bind
|
o: bind
|
||||||
# This directory specified where InvenTree source code is stored "outside" the docker containers
|
# This directory specified where InvenTree source code is stored "outside" the docker containers
|
||||||
# By default, this directory is one level above the "docker" directory
|
# By default, this directory is one level above the "docker" directory
|
||||||
device: ${INVENTREE_EXT_VOLUME:-../}
|
device: ${INVENTREE_EXT_VOLUME:-./}
|
@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
# exit when any command fails
|
# exit when any command fails
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
@ -16,6 +16,12 @@ INVENTREE_WEB_PORT=1337
|
|||||||
INVENTREE_DEBUG=False
|
INVENTREE_DEBUG=False
|
||||||
INVENTREE_LOG_LEVEL=WARNING
|
INVENTREE_LOG_LEVEL=WARNING
|
||||||
|
|
||||||
|
# InvenTree admin account details
|
||||||
|
# Un-comment (and complete) these lines to auto-create an admin acount
|
||||||
|
#INVENTREE_ADMIN_USER=
|
||||||
|
#INVENTREE_ADMIN_PASSWORD=
|
||||||
|
#INVENTREE_ADMIN_EMAIL=
|
||||||
|
|
||||||
# Database configuration options
|
# Database configuration options
|
||||||
# Note: The example setup is for a PostgreSQL database
|
# Note: The example setup is for a PostgreSQL database
|
||||||
INVENTREE_DB_ENGINE=postgresql
|
INVENTREE_DB_ENGINE=postgresql
|
||||||
|
@ -29,16 +29,16 @@ django-sslserver==0.22 # Secure HTTP development server
|
|||||||
django-stdimage==5.1.1 # Advanced ImageField management
|
django-stdimage==5.1.1 # Advanced ImageField management
|
||||||
django-test-migrations==1.1.0 # Unit testing for database migrations
|
django-test-migrations==1.1.0 # Unit testing for database migrations
|
||||||
django-user-sessions==1.7.1 # user sessions in DB
|
django-user-sessions==1.7.1 # user sessions in DB
|
||||||
django-weasyprint==1.0.1 # django weasyprint integration
|
django-weasyprint==2.1.0 # django weasyprint integration
|
||||||
djangorestframework==3.12.4 # DRF framework
|
djangorestframework==3.12.4 # DRF framework
|
||||||
django-xforwardedfor-middleware==2.0 # IP forwarding metadata
|
django-xforwardedfor-middleware==2.0 # IP forwarding metadata
|
||||||
flake8==3.8.3 # PEP checking
|
flake8==3.8.3 # PEP checking
|
||||||
flake8-docstrings==1.6.0 # docstring format testing
|
flake8-docstrings==1.6.0 # docstring format testing
|
||||||
gunicorn>=20.1.0 # Gunicorn web server
|
gunicorn>=20.1.0 # Gunicorn web server
|
||||||
importlib_metadata # Backport for importlib.metadata
|
importlib_metadata # Backport for importlib.metadata
|
||||||
inventree # Install the latest version of the InvenTree API python library
|
|
||||||
isort==5.10.1 # DEV: python import sorting
|
isort==5.10.1 # DEV: python import sorting
|
||||||
markdown==3.3.4 # Force particular version of markdown
|
markdown==3.3.4 # Force particular version of markdown
|
||||||
|
pdf2image==1.16.0 # PDF to image conversion
|
||||||
pep8-naming==0.11.1 # PEP naming convention extension
|
pep8-naming==0.11.1 # PEP naming convention extension
|
||||||
pre-commit==2.19.0 # Git pre-commit
|
pre-commit==2.19.0 # Git pre-commit
|
||||||
pillow==9.1.0 # Image manipulation
|
pillow==9.1.0 # Image manipulation
|
||||||
@ -48,4 +48,4 @@ python-barcode[images]==0.13.1 # Barcode generator
|
|||||||
qrcode[pil]==6.1 # QR code generator
|
qrcode[pil]==6.1 # QR code generator
|
||||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||||
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
||||||
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
|
weasyprint==55.0 # PDF generation library
|
||||||
|
@ -15,6 +15,8 @@ ignore =
|
|||||||
N806,
|
N806,
|
||||||
# - N812 - lowercase imported as non-lowercase
|
# - N812 - lowercase imported as non-lowercase
|
||||||
N812,
|
N812,
|
||||||
|
# - D202 - No blank lines allowed after function docstring
|
||||||
|
D202,
|
||||||
# - D415 - First line should end with a period, question mark, or exclamation point
|
# - D415 - First line should end with a period, question mark, or exclamation point
|
||||||
D415,
|
D415,
|
||||||
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/*
|
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/*
|
||||||
|
4
tasks.py
4
tasks.py
@ -66,7 +66,7 @@ def plugins(c):
|
|||||||
print(f"Installing plugin packages from '{plugin_file}'")
|
print(f"Installing plugin packages from '{plugin_file}'")
|
||||||
|
|
||||||
# Install the plugins
|
# Install the plugins
|
||||||
c.run(f"pip3 install -U -r '{plugin_file}'")
|
c.run(f"pip3 install --disable-pip-version-check -U -r '{plugin_file}'")
|
||||||
|
|
||||||
|
|
||||||
@task(post=[plugins])
|
@task(post=[plugins])
|
||||||
@ -75,7 +75,7 @@ def install(c):
|
|||||||
print("Installing required python packages from 'requirements.txt'")
|
print("Installing required python packages from 'requirements.txt'")
|
||||||
|
|
||||||
# Install required Python packages with PIP
|
# Install required Python packages with PIP
|
||||||
c.run('pip3 install -U -r requirements.txt')
|
c.run('pip3 install --no-cache-dir --disable-pip-version-check -U -r requirements.txt')
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
|
Reference in New Issue
Block a user