mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master' into docupdates
# Conflicts: # .github/workflows/qc_checks.yaml # InvenTree/InvenTree/version.py # InvenTree/common/notifications.py # InvenTree/label/api.py # InvenTree/plugin/base/label/label.py # InvenTree/plugin/base/label/mixins.py # InvenTree/plugin/base/label/test_label_mixin.py # InvenTree/plugin/registry.py
This commit is contained in:
commit
99676eef6d
@ -1,4 +1,5 @@
|
||||
# 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
|
||||
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'
|
||||
- name: Run pre-commit Checks
|
||||
uses: pre-commit/action@v2.0.3
|
||||
- name: Check version number
|
||||
run: |
|
||||
python3 ci/check_version_number.py
|
||||
|
||||
python:
|
||||
name: Tests - inventree-python
|
||||
@ -114,7 +117,7 @@ jobs:
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext
|
||||
apt-dependency: gettext poppler-utils
|
||||
update: true
|
||||
- name: Download Python Code For `${{ env.wrapper_name }}`
|
||||
run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }}
|
||||
@ -130,22 +133,6 @@ jobs:
|
||||
invoke check-server
|
||||
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:
|
||||
name: Tests - DB [SQLite] + Coverage
|
||||
runs-on: ubuntu-20.04
|
||||
@ -163,7 +150,7 @@ jobs:
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext
|
||||
apt-dependency: gettext poppler-utils
|
||||
update: true
|
||||
- name: Coverage Tests
|
||||
run: invoke coverage
|
||||
@ -212,7 +199,7 @@ jobs:
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext libpq-dev
|
||||
apt-dependency: gettext poppler-utils libpq-dev
|
||||
pip-dependency: psycopg2 django-redis>=5.0.0
|
||||
update: true
|
||||
- name: Run Tests
|
||||
@ -255,7 +242,7 @@ jobs:
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext libmysqlclient-dev
|
||||
apt-dependency: gettext poppler-utils libmysqlclient-dev
|
||||
pip-dependency: mysqlclient
|
||||
update: true
|
||||
- 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/
|
||||
inventree-env/
|
||||
./build/
|
||||
.cache/
|
||||
develop-eggs/
|
||||
dist/
|
||||
bin/
|
||||
@ -26,7 +27,6 @@ var/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
@ -38,6 +38,8 @@ local_settings.py
|
||||
# Files used for testing
|
||||
dummy_image.*
|
||||
_tmp.csv
|
||||
inventree/label.pdf
|
||||
inventree/label.png
|
||||
|
||||
# Sphinx files
|
||||
docs/_build
|
||||
@ -63,6 +65,7 @@ secret_key.txt
|
||||
.idea/
|
||||
*.code-workspace
|
||||
.vscode/
|
||||
.bash_history
|
||||
|
||||
# Coverage reports
|
||||
.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
|
||||
ARG tag=""
|
||||
FROM python:3.9-slim as base
|
||||
|
||||
# Build arguments for this image
|
||||
ARG commit_hash=""
|
||||
ARG commit_date=""
|
||||
ARG commit_tag=""
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Ref: https://github.com/pyca/cryptography/issues/5776
|
||||
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_DOCKER="true"
|
||||
|
||||
# InvenTree paths
|
||||
ENV INVENTREE_HOME="/home/inventree"
|
||||
ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
|
||||
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
|
||||
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
||||
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
||||
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
|
||||
|
||||
# InvenTree configuration files
|
||||
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
|
||||
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.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.name="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-branch=${INVENTREE_GIT_BRANCH} \
|
||||
org.label-schema.vcs-ref=${INVENTREE_GIT_TAG}
|
||||
org.label-schema.vcs-url="https://github.com/inventree/InvenTree.git" \
|
||||
org.label-schema.vcs-ref=${commit_tag}
|
||||
|
||||
# Create user account
|
||||
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
|
||||
|
||||
RUN apk -U upgrade
|
||||
# RUN apt-get upgrade && apt-get update
|
||||
RUN apt-get update
|
||||
|
||||
# Install required system packages
|
||||
RUN apk add --no-cache git make bash \
|
||||
gcc libgcc g++ libstdc++ \
|
||||
gnupg \
|
||||
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev libwebp-dev \
|
||||
libffi libffi-dev \
|
||||
zlib zlib-dev \
|
||||
# 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 \
|
||||
RUN apt-get install -y --no-install-recommends \
|
||||
git gcc g++ gettext gnupg libffi-dev \
|
||||
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
|
||||
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
|
||||
# Image format support
|
||||
libjpeg-dev webp \
|
||||
# SQLite support
|
||||
sqlite \
|
||||
sqlite3 \
|
||||
# PostgreSQL support
|
||||
postgresql postgresql-contrib postgresql-dev libpq \
|
||||
# MySQL/MariaDB support
|
||||
mariadb-connector-c mariadb-dev mariadb-client \
|
||||
# Required for python cryptography support
|
||||
openssl-dev musl-dev libffi-dev rust cargo
|
||||
libpq-dev \
|
||||
# MySQL / MariaDB support
|
||||
default-libmysqlclient-dev mariadb-client && \
|
||||
apt-get autoclean && apt-get autoremove
|
||||
|
||||
# Update pip
|
||||
RUN pip install --upgrade pip
|
||||
|
||||
# Install required base-level python packages
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install --no-cache-dir -U -r requirements.txt
|
||||
COPY ./docker/requirements.txt base_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
|
||||
|
||||
# Clone source code
|
||||
RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}"
|
||||
ENV INVENTREE_DEBUG=False
|
||||
|
||||
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/
|
||||
RUN git config --global --add safe.directory ${INVENTREE_HOME}
|
||||
# Copy source code
|
||||
COPY InvenTree ${INVENTREE_HOME}/InvenTree
|
||||
|
||||
# Checkout against a particular git tag
|
||||
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
|
||||
|
||||
RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/*
|
||||
|
||||
# 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
|
||||
# Copy other key files
|
||||
COPY requirements.txt ${INVENTREE_HOME}/requirements.txt
|
||||
COPY tasks.py ${INVENTREE_HOME}/tasks.py
|
||||
COPY docker/gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
|
||||
COPY docker/init.sh ${INVENTREE_MNG_DIR}/init.sh
|
||||
|
||||
# Need to be running from within this directory
|
||||
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
|
||||
ENTRYPOINT ["/bin/bash", "../docker/init.sh"]
|
||||
ENTRYPOINT ["/bin/bash", "./init.sh"]
|
||||
|
||||
# Launch the production server
|
||||
# TODO: Work out why environment variables cannot be interpolated in this command
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
ENV INVENTREE_DEBUG=True
|
||||
|
||||
ENV INVENTREE_DEV_DIR="${INVENTREE_HOME}/dev"
|
||||
|
||||
# Location for python virtual environment
|
@ -106,6 +106,11 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||
response = self.client.get(url, data, format='json')
|
||||
|
||||
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)
|
||||
|
||||
return response
|
||||
|
@ -36,7 +36,11 @@ def exception_handler(exc, context):
|
||||
if response is None:
|
||||
# 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)
|
||||
else:
|
||||
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
|
||||
"""
|
||||
return os.path.splitext(os.path.basename(url))[-1].lower() in [
|
||||
'.jpg', '.jpeg',
|
||||
'.jpg', '.jpeg', '.j2k',
|
||||
'.png', '.bmp',
|
||||
'.tif', '.tiff',
|
||||
'.webp', '.gif',
|
||||
|
@ -366,6 +366,30 @@ class TestVersionNumber(TestCase):
|
||||
self.assertTrue(v_d > v_c)
|
||||
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):
|
||||
"""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
|
||||
InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user)
|
||||
registry.reload_plugins()
|
||||
registry.reload_plugins(full_reload=True)
|
||||
|
||||
# Check that there was anotehr run
|
||||
response = registry.install_plugin_file()
|
||||
|
@ -1,8 +1,9 @@
|
||||
"""Version information for InvenTree.
|
||||
|
||||
"""
|
||||
Version information for InvenTree.
|
||||
Provides information on the current InvenTree version
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
@ -16,12 +17,12 @@ INVENTREE_SW_VERSION = "0.8.0 dev"
|
||||
|
||||
|
||||
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", "")
|
||||
|
||||
|
||||
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):
|
||||
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
||||
else:
|
||||
@ -29,12 +30,13 @@ def inventreeInstanceTitle():
|
||||
|
||||
|
||||
def inventreeVersion():
|
||||
"""Returns the InvenTree version string."""
|
||||
""" Returns the InvenTree version string """
|
||||
return INVENTREE_SW_VERSION.lower().strip()
|
||||
|
||||
|
||||
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:
|
||||
version = INVENTREE_SW_VERSION
|
||||
|
||||
@ -44,16 +46,21 @@ def inventreeVersionTuple(version=None):
|
||||
|
||||
|
||||
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')
|
||||
|
||||
|
||||
def inventreeDocsVersion():
|
||||
"""Return the version string matching the latest documentation.
|
||||
"""
|
||||
Return the version string matching the latest documentation.
|
||||
|
||||
Development -> "latest"
|
||||
Release -> "major.minor.sub" e.g. "0.5.2"
|
||||
|
||||
"""
|
||||
|
||||
if isInvenTreeDevelopmentVersion():
|
||||
return "latest"
|
||||
else:
|
||||
@ -61,10 +68,13 @@ def inventreeDocsVersion():
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# No record for "latest" version - we must assume we are up to date!
|
||||
@ -83,12 +93,19 @@ def inventreeApiVersion():
|
||||
|
||||
|
||||
def inventreeDjangoVersion():
|
||||
"""Return the version of Django library."""
|
||||
""" Return the version of Django library """
|
||||
return django.get_version()
|
||||
|
||||
|
||||
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:
|
||||
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
||||
except: # pragma: no cover
|
||||
@ -96,7 +113,14 @@ def inventreeCommitHash():
|
||||
|
||||
|
||||
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:
|
||||
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
||||
return d.split(' ')[0]
|
||||
|
@ -12,7 +12,9 @@ logger = logging.getLogger('inventree')
|
||||
|
||||
# region methods
|
||||
class NotificationMethod:
|
||||
"""Base class for notification methods."""
|
||||
"""
|
||||
Base class for notification methods
|
||||
"""
|
||||
|
||||
METHOD_NAME = ''
|
||||
METHOD_ICON = None
|
||||
@ -90,11 +92,11 @@ class NotificationMethod:
|
||||
|
||||
# region plugins
|
||||
def get_plugin(self):
|
||||
"""Returns plugin class."""
|
||||
"""Returns plugin class"""
|
||||
return False
|
||||
|
||||
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
|
||||
if not self.GLOBAL_SETTING:
|
||||
return False
|
||||
@ -113,7 +115,9 @@ class NotificationMethod:
|
||||
return False
|
||||
|
||||
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)
|
||||
# endregion
|
||||
|
||||
@ -199,8 +203,11 @@ class UIMessageNotification(SingleNotificationMethod):
|
||||
return True
|
||||
|
||||
|
||||
def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs):
|
||||
"""Send out a notification."""
|
||||
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
"""
|
||||
Send out a notification
|
||||
"""
|
||||
|
||||
targets = kwargs.get('targets', None)
|
||||
target_fnc = kwargs.get('target_fnc', None)
|
||||
target_args = kwargs.get('target_args', [])
|
||||
|
@ -1,12 +1,9 @@
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.urls import include, re_path
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from PIL import Image
|
||||
from rest_framework import filters, generics
|
||||
from rest_framework.exceptions import NotFound
|
||||
|
||||
@ -24,7 +21,9 @@ from .serializers import (PartLabelSerializer, StockItemLabelSerializer,
|
||||
|
||||
|
||||
class LabelListView(generics.ListAPIView):
|
||||
"""Generic API class for label templates."""
|
||||
"""
|
||||
Generic API class for label templates
|
||||
"""
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
@ -42,10 +41,14 @@ class LabelListView(generics.ListAPIView):
|
||||
|
||||
|
||||
class LabelPrintMixin:
|
||||
"""Mixin for printing labels."""
|
||||
"""
|
||||
Mixin for printing labels
|
||||
"""
|
||||
|
||||
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:
|
||||
- settings.PLUGINS_ENABLED is True
|
||||
@ -53,6 +56,7 @@ class LabelPrintMixin:
|
||||
- matching plugin implements the 'labels' mixin
|
||||
- matching plugin is enabled
|
||||
"""
|
||||
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
return None # pragma: no cover
|
||||
|
||||
@ -76,7 +80,10 @@ class LabelPrintMixin:
|
||||
raise NotFound(f"Plugin '{plugin_key}' not found")
|
||||
|
||||
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
|
||||
plugin = self.get_plugin(request)
|
||||
|
||||
@ -113,34 +120,35 @@ class LabelPrintMixin:
|
||||
|
||||
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:
|
||||
|
||||
- Individually generate each label, exporting as an image file
|
||||
- Pass all the images through to the label printing plugin
|
||||
- Return a JSON response indicating that the printing has been offloaded
|
||||
|
||||
"""
|
||||
|
||||
# Label instance
|
||||
label_instance = self.get_object()
|
||||
|
||||
for output in outputs:
|
||||
"""For each output, we generate a temporary image file, which will then get sent to the printer."""
|
||||
for idx, output in enumerate(outputs):
|
||||
"""
|
||||
For each output, we generate a temporary image file,
|
||||
which will then get sent to the printer
|
||||
"""
|
||||
|
||||
# Generate a png image at 300dpi
|
||||
(img_data, w, h) = output.get_document().write_png(resolution=300)
|
||||
|
||||
# Construct a BytesIO object, which can be read by pillow
|
||||
img_bytes = BytesIO(img_data)
|
||||
|
||||
image = Image.open(img_bytes)
|
||||
# Generate PDF data for the label
|
||||
pdf = output.get_document().write_pdf()
|
||||
|
||||
# Offload a background task to print the provided label
|
||||
offload_task(
|
||||
plugin_label.print_label,
|
||||
plugin.plugin_slug(),
|
||||
image,
|
||||
pdf,
|
||||
filename=label_names[idx],
|
||||
label_instance=label_instance,
|
||||
user=request.user,
|
||||
)
|
||||
@ -151,14 +159,20 @@ class LabelPrintMixin:
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
return HttpResponse(html)
|
||||
|
||||
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 = []
|
||||
|
||||
@ -184,10 +198,15 @@ class LabelPrintMixin:
|
||||
|
||||
|
||||
class StockItemLabelMixin:
|
||||
"""Mixin for extracting stock items from query params."""
|
||||
"""
|
||||
Mixin for extracting stock items from query params
|
||||
"""
|
||||
|
||||
def get_items(self):
|
||||
"""Return a list of requested stock items."""
|
||||
"""
|
||||
Return a list of requested stock items
|
||||
"""
|
||||
|
||||
items = []
|
||||
|
||||
params = self.request.query_params
|
||||
@ -212,20 +231,25 @@ class StockItemLabelMixin:
|
||||
|
||||
|
||||
class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
||||
"""API endpoint for viewing list of StockItemLabel objects.
|
||||
"""
|
||||
API endpoint for viewing list of StockItemLabel objects.
|
||||
|
||||
Filterable by:
|
||||
|
||||
- enabled: Filter by enabled / disabled status
|
||||
- item: Filter by single stock item
|
||||
- items: Filter by list of stock items
|
||||
|
||||
"""
|
||||
|
||||
queryset = StockItemLabel.objects.all()
|
||||
serializer_class = StockItemLabelSerializer
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Filter the StockItem label queryset."""
|
||||
"""
|
||||
Filter the StockItem label queryset.
|
||||
"""
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# List of StockItem objects to match against
|
||||
@ -234,7 +258,9 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
||||
# We wish to filter by stock items
|
||||
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
|
||||
will need to be readdressed.
|
||||
@ -278,30 +304,42 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
||||
|
||||
|
||||
class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""API endpoint for a single StockItemLabel object."""
|
||||
"""
|
||||
API endpoint for a single StockItemLabel object
|
||||
"""
|
||||
|
||||
queryset = StockItemLabel.objects.all()
|
||||
serializer_class = StockItemLabelSerializer
|
||||
|
||||
|
||||
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin):
|
||||
"""API endpoint for printing a StockItemLabel object."""
|
||||
"""
|
||||
API endpoint for printing a StockItemLabel object
|
||||
"""
|
||||
|
||||
queryset = StockItemLabel.objects.all()
|
||||
serializer_class = StockItemLabelSerializer
|
||||
|
||||
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()
|
||||
|
||||
return self.print(request, items)
|
||||
|
||||
|
||||
class StockLocationLabelMixin:
|
||||
"""Mixin for extracting stock locations from query params."""
|
||||
"""
|
||||
Mixin for extracting stock locations from query params
|
||||
"""
|
||||
|
||||
def get_locations(self):
|
||||
"""Return a list of requested stock locations."""
|
||||
"""
|
||||
Return a list of requested stock locations
|
||||
"""
|
||||
|
||||
locations = []
|
||||
|
||||
params = self.request.query_params
|
||||
@ -326,7 +364,8 @@ class StockLocationLabelMixin:
|
||||
|
||||
|
||||
class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
||||
"""API endpoint for viewiing list of StockLocationLabel objects.
|
||||
"""
|
||||
API endpoint for viewiing list of StockLocationLabel objects.
|
||||
|
||||
Filterable by:
|
||||
|
||||
@ -339,7 +378,10 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
||||
serializer_class = StockLocationLabelSerializer
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Filter the StockLocationLabel queryset."""
|
||||
"""
|
||||
Filter the StockLocationLabel queryset
|
||||
"""
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# List of StockLocation objects to match against
|
||||
@ -348,7 +390,9 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
||||
# We wish to filter by stock location(s)
|
||||
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
|
||||
will need to be readdressed.
|
||||
@ -392,14 +436,18 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
||||
|
||||
|
||||
class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""API endpoint for a single StockLocationLabel object."""
|
||||
"""
|
||||
API endpoint for a single StockLocationLabel object
|
||||
"""
|
||||
|
||||
queryset = StockLocationLabel.objects.all()
|
||||
serializer_class = StockLocationLabelSerializer
|
||||
|
||||
|
||||
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin):
|
||||
"""API endpoint for printing a StockLocationLabel object."""
|
||||
"""
|
||||
API endpoint for printing a StockLocationLabel object
|
||||
"""
|
||||
|
||||
queryset = StockLocationLabel.objects.all()
|
||||
seiralizer_class = StockLocationLabelSerializer
|
||||
@ -412,10 +460,15 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin,
|
||||
|
||||
|
||||
class PartLabelMixin:
|
||||
"""Mixin for extracting Part objects from query parameters."""
|
||||
"""
|
||||
Mixin for extracting Part objects from query parameters
|
||||
"""
|
||||
|
||||
def get_parts(self):
|
||||
"""Return a list of requested Part objects."""
|
||||
"""
|
||||
Return a list of requested Part objects
|
||||
"""
|
||||
|
||||
parts = []
|
||||
|
||||
params = self.request.query_params
|
||||
@ -438,7 +491,9 @@ class 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()
|
||||
serializer_class = PartLabelSerializer
|
||||
@ -484,20 +539,27 @@ class PartLabelList(LabelListView, PartLabelMixin):
|
||||
|
||||
|
||||
class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""API endpoint for a single PartLabel object."""
|
||||
"""
|
||||
API endpoint for a single PartLabel object
|
||||
"""
|
||||
|
||||
queryset = PartLabel.objects.all()
|
||||
serializer_class = PartLabelSerializer
|
||||
|
||||
|
||||
class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin):
|
||||
"""API endpoint for printing a PartLabel object."""
|
||||
"""
|
||||
API endpoint for printing a PartLabel object
|
||||
"""
|
||||
|
||||
queryset = PartLabel.objects.all()
|
||||
serializer_class = PartLabelSerializer
|
||||
|
||||
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()
|
||||
|
||||
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
@ -24,7 +24,7 @@ def notify_low_stock(part: part.models.Part):
|
||||
},
|
||||
}
|
||||
|
||||
common.notifications.trigger_notifaction(
|
||||
common.notifications.trigger_notification(
|
||||
part,
|
||||
'part.notify_low_stock',
|
||||
target_fnc=part.get_subscribers,
|
||||
|
@ -1052,7 +1052,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
self.assertIn('Upload a valid image', str(response.data))
|
||||
|
||||
# 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}'
|
||||
|
||||
img = PIL.Image.new('RGB', (128, 128), color='red')
|
||||
|
@ -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 sys
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
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
|
||||
from plugin.registry import registry
|
||||
@ -10,19 +16,21 @@ from plugin.registry import registry
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def print_label(plugin_slug: str, label_image, label_instance=None, user=None):
|
||||
"""Print label with the provided plugin.
|
||||
def print_label(plugin_slug, pdf_data, filename=None, label_instance=None, user=None):
|
||||
"""
|
||||
Print label with the provided plugin.
|
||||
|
||||
This task is nominally handled by the background worker.
|
||||
|
||||
If the printing fails (throws an exception) then the user is notified.
|
||||
|
||||
Args:
|
||||
plugin_slug (str): The unique slug (key) of the plugin
|
||||
label_image (_type_): A PIL.Image image object to be printed
|
||||
label_instance (Union[LabelTemplate, None], optional): The template instance that should be printed. Defaults to None.
|
||||
user (Union[User, None], optional): User that should be informed of errors. Defaults to None.
|
||||
Arguments:
|
||||
plugin_slug: The unique slug (key) of the plugin
|
||||
pdf_data: Binary PDF data
|
||||
filename: The intended name of the printed label
|
||||
"""
|
||||
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)
|
||||
|
||||
@ -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}'")
|
||||
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:
|
||||
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
|
||||
# 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),
|
||||
}
|
||||
|
||||
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
|
||||
common.notifications.trigger_notifaction(
|
||||
common.notifications.trigger_notification(
|
||||
plugin.plugin_config(),
|
||||
'label.printing_failed',
|
||||
targets=[user],
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@ -12,23 +13,28 @@ class LabelPrintingMixin:
|
||||
"""
|
||||
|
||||
class MixinMeta:
|
||||
"""Meta options for this mixin."""
|
||||
|
||||
"""
|
||||
Meta options for this mixin
|
||||
"""
|
||||
MIXIN_NAME = 'Label printing'
|
||||
|
||||
def __init__(self): # pragma: no cover
|
||||
super().__init__()
|
||||
self.add_mixin('labels', True, __class__)
|
||||
|
||||
def print_label(self, label, **kwargs):
|
||||
"""Callback to print a single label.
|
||||
|
||||
Arguments:
|
||||
label: A black-and-white pillow Image object
|
||||
def print_label(self, **kwargs):
|
||||
"""
|
||||
Callback to print a single label
|
||||
|
||||
kwargs:
|
||||
length: The length of the label (in mm)
|
||||
width: The width of the label (in mm)
|
||||
pdf_data: Raw PDF data of the rendered label
|
||||
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)
|
||||
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.urls import reverse
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from label.models import PartLabel, StockItemLabel, StockLocationLabel
|
||||
@ -15,7 +18,7 @@ from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
class LabelMixinTests(InvenTreeAPITestCase):
|
||||
"""Test that the Label mixin operates correctly."""
|
||||
"""Test that the Label mixin operates correctly"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -27,13 +30,14 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
roles = 'all'
|
||||
|
||||
def do_activate_plugin(self):
|
||||
"""Activate the 'samplelabel' plugin."""
|
||||
"""Activate the 'samplelabel' plugin"""
|
||||
|
||||
config = registry.get_plugin('samplelabel').plugin_config()
|
||||
config.active = True
|
||||
config.save()
|
||||
|
||||
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
|
||||
kwargs = {}
|
||||
if label:
|
||||
@ -60,16 +64,18 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
return url
|
||||
|
||||
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):
|
||||
pass
|
||||
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
plugin = WrongPlugin()
|
||||
plugin.print_label('test')
|
||||
plugin.print_label(filename='test')
|
||||
|
||||
def test_installed(self):
|
||||
"""Test that the sample printing plugin is installed."""
|
||||
"""Test that the sample printing plugin is installed"""
|
||||
|
||||
# Get all label plugins
|
||||
plugins = registry.with_mixin('labels')
|
||||
self.assertEqual(len(plugins), 1)
|
||||
@ -79,7 +85,8 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(plugins), 0)
|
||||
|
||||
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')
|
||||
|
||||
# Try POST (disallowed)
|
||||
@ -123,7 +130,8 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
self.assertEqual(data['key'], 'samplelabel')
|
||||
|
||||
def test_printing_process(self):
|
||||
"""Test that a label can be printed."""
|
||||
"""Test that a label can be printed"""
|
||||
|
||||
# Ensure the labels were created
|
||||
apps.get_app_config('label').create_labels()
|
||||
|
||||
@ -162,6 +170,21 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
# Print no part
|
||||
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):
|
||||
"""Cover the endpoints not covered by `test_printing_process`"""
|
||||
plugin_ref = 'samplelabel'
|
||||
@ -171,7 +194,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
self.do_activate_plugin()
|
||||
|
||||
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:
|
||||
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
|
||||
- Manages setup and teardown of plugin class instances
|
||||
@ -30,7 +31,9 @@ logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class PluginsRegistry:
|
||||
"""The PluginsRegistry class."""
|
||||
"""
|
||||
The PluginsRegistry class
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# plugin registry
|
||||
@ -52,7 +55,10 @@ class PluginsRegistry:
|
||||
self.mixins_settings = {}
|
||||
|
||||
def get_plugin(self, slug):
|
||||
"""Lookup plugin by slug (unique key)."""
|
||||
"""
|
||||
Lookup plugin by slug (unique key).
|
||||
"""
|
||||
|
||||
if slug not in self.plugins:
|
||||
logger.warning(f"Plugin registry has no record of plugin '{slug}'")
|
||||
return None
|
||||
@ -60,13 +66,15 @@ class PluginsRegistry:
|
||||
return self.plugins[slug]
|
||||
|
||||
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,
|
||||
we do not perform any try/except here.
|
||||
|
||||
Instead, any error messages are returned to the worker.
|
||||
"""
|
||||
|
||||
plugin = self.get_plugin(slug)
|
||||
|
||||
if not plugin:
|
||||
@ -78,8 +86,12 @@ class PluginsRegistry:
|
||||
|
||||
# region public functions
|
||||
# region loading / unloading
|
||||
def load_plugins(self):
|
||||
"""Load and activate all IntegrationPlugins."""
|
||||
def load_plugins(self, full_reload: bool = False):
|
||||
"""Load and activate all IntegrationPlugins
|
||||
|
||||
Args:
|
||||
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
|
||||
"""
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
# Plugins not enabled, do nothing
|
||||
return # pragma: no cover
|
||||
@ -99,7 +111,7 @@ class PluginsRegistry:
|
||||
try:
|
||||
# We are using the db so for migrations etc we need to try this block
|
||||
self._init_plugins(blocked_plugin)
|
||||
self._activate_plugins()
|
||||
self._activate_plugins(full_reload=full_reload)
|
||||
registered_successful = True
|
||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||
# Exception if the database has not been migrated yet
|
||||
@ -113,7 +125,7 @@ class PluginsRegistry:
|
||||
# Initialize apps without any plugins
|
||||
self._clean_registry()
|
||||
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
|
||||
retry_counter -= 1
|
||||
@ -127,6 +139,10 @@ class PluginsRegistry:
|
||||
|
||||
# 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
|
||||
if not _maintenance:
|
||||
set_maintenance_mode(False)
|
||||
@ -134,7 +150,10 @@ class PluginsRegistry:
|
||||
logger.info('Finished loading plugins')
|
||||
|
||||
def unload_plugins(self):
|
||||
"""Unload and deactivate all IntegrationPlugins."""
|
||||
"""
|
||||
Unload and deactivate all IntegrationPlugins
|
||||
"""
|
||||
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
# Plugins not enabled, do nothing
|
||||
return # pragma: no cover
|
||||
@ -157,8 +176,13 @@ class PluginsRegistry:
|
||||
set_maintenance_mode(False) # pragma: no cover
|
||||
logger.info('Finished unloading plugins')
|
||||
|
||||
def reload_plugins(self):
|
||||
"""Safely reload IntegrationPlugins."""
|
||||
def reload_plugins(self, full_reload: bool = False):
|
||||
"""Safely reload IntegrationPlugins
|
||||
|
||||
Args:
|
||||
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
|
||||
"""
|
||||
|
||||
# Do not reload whe currently loading
|
||||
if self.is_loading:
|
||||
return # pragma: no cover
|
||||
@ -167,12 +191,13 @@ class PluginsRegistry:
|
||||
|
||||
with maintenance_mode_on():
|
||||
self.unload_plugins()
|
||||
self.load_plugins()
|
||||
self.load_plugins(full_reload)
|
||||
|
||||
logger.info('Finished reloading plugins')
|
||||
|
||||
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:
|
||||
# Plugins not enabled, do nothing
|
||||
return # pragma: no cover
|
||||
@ -201,7 +226,10 @@ class PluginsRegistry:
|
||||
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
|
||||
|
||||
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:
|
||||
logger.info('Plugin file was already checked')
|
||||
return True
|
||||
@ -222,7 +250,9 @@ class PluginsRegistry:
|
||||
|
||||
# region registry functions
|
||||
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 = []
|
||||
|
||||
for plugin in self.plugins.values():
|
||||
@ -243,12 +273,14 @@ class PluginsRegistry:
|
||||
|
||||
# region general internal loading /activating / deactivating / deloading
|
||||
def _init_plugins(self, disabled=None):
|
||||
"""Initialise all found plugins.
|
||||
"""
|
||||
Initialise all found plugins
|
||||
|
||||
:param disabled: loading path of disabled app, defaults to None
|
||||
:type disabled: str, optional
|
||||
:raises error: IntegrationPluginError
|
||||
"""
|
||||
|
||||
from plugin.models import PluginConfig
|
||||
|
||||
logger.info('Starting plugin initialisation')
|
||||
@ -311,11 +343,12 @@ class PluginsRegistry:
|
||||
# save for later reference
|
||||
self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover
|
||||
|
||||
def _activate_plugins(self, force_reload=False):
|
||||
"""Run activation functions for all plugins.
|
||||
def _activate_plugins(self, force_reload=False, full_reload: bool = False):
|
||||
"""Run activation functions for all plugins
|
||||
|
||||
:param force_reload: force reload base apps, defaults to False
|
||||
:type force_reload: bool, optional
|
||||
Args:
|
||||
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
|
||||
plugins = self.plugins.items()
|
||||
@ -323,10 +356,11 @@ class PluginsRegistry:
|
||||
|
||||
self.activate_plugin_settings(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):
|
||||
"""Run deactivation functions for all plugins."""
|
||||
"""Run deactivation functions for all plugins"""
|
||||
|
||||
self.deactivate_plugin_app()
|
||||
self.deactivate_plugin_schedule()
|
||||
self.deactivate_plugin_settings()
|
||||
@ -400,20 +434,21 @@ class PluginsRegistry:
|
||||
logger.warning("activate_integration_schedule failed, database not ready")
|
||||
|
||||
def deactivate_plugin_schedule(self):
|
||||
"""Deactivate ScheduleMixin.
|
||||
|
||||
"""
|
||||
Deactivate ScheduleMixin
|
||||
currently nothing is done
|
||||
"""
|
||||
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
|
||||
|
||||
:param plugins: list of IntegrationPlugins that should be installed
|
||||
:type plugins: dict
|
||||
:param force_reload: only reload base apps, defaults to False
|
||||
:type force_reload: bool, optional
|
||||
Args:
|
||||
plugins (dict): List of IntegrationPlugins that should be installed
|
||||
force_reload (bool, optional): Only reload base apps. Defaults to False.
|
||||
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
|
||||
"""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
|
||||
@ -434,9 +469,9 @@ class PluginsRegistry:
|
||||
# first startup or force loading of base apps -> registry is prob false
|
||||
if self.apps_loading or force_reload:
|
||||
self.apps_loading = False
|
||||
self._reload_apps(force_reload=True)
|
||||
self._reload_apps(force_reload=True, full_reload=full_reload)
|
||||
else:
|
||||
self._reload_apps()
|
||||
self._reload_apps(full_reload=full_reload)
|
||||
|
||||
# rediscover models/ admin sites
|
||||
self._reregister_contrib_apps()
|
||||
@ -445,10 +480,9 @@ class PluginsRegistry:
|
||||
self._update_urls()
|
||||
|
||||
def _reregister_contrib_apps(self):
|
||||
"""Fix reloading of contrib apps - models and admin
|
||||
|
||||
This is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports.
|
||||
Those register models and admin in their respective objects (e.g. admin.site for admin).
|
||||
"""fix reloading of contrib apps - models and admin
|
||||
this is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports
|
||||
those register models and admin in their respective objects (e.g. admin.site for admin)
|
||||
"""
|
||||
for plugin_path in self.installed_apps:
|
||||
try:
|
||||
@ -478,9 +512,8 @@ class PluginsRegistry:
|
||||
reload(app_config.module.admin)
|
||||
|
||||
def _get_plugin_path(self, plugin):
|
||||
"""Parse plugin path.
|
||||
|
||||
The input can be eiter:
|
||||
"""parse plugin path
|
||||
the input can be eiter:
|
||||
- a local file / dir
|
||||
- a package
|
||||
"""
|
||||
@ -494,6 +527,7 @@ class PluginsRegistry:
|
||||
|
||||
def deactivate_plugin_app(self):
|
||||
"""Deactivate AppMixin plugins - some magic required"""
|
||||
|
||||
# unregister models from admin
|
||||
for plugin_path in self.installed_apps:
|
||||
models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed
|
||||
@ -563,7 +597,16 @@ class PluginsRegistry:
|
||||
global_pattern[0] = re_path('', include(urlpatterns))
|
||||
clear_url_caches()
|
||||
|
||||
def _reload_apps(self, force_reload: bool = False):
|
||||
def _reload_apps(self, force_reload: bool = False, full_reload: bool = False):
|
||||
"""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:
|
||||
# we can not use the built in functions as we need to brute force the registry
|
||||
@ -576,9 +619,9 @@ class PluginsRegistry:
|
||||
self.is_loading = False
|
||||
|
||||
def _try_reload(self, cmd, *args, **kwargs):
|
||||
"""Wrapper to try reloading the apps.
|
||||
|
||||
Throws an custom error that gets handled by the loading function
|
||||
"""
|
||||
wrapper to try reloading the apps
|
||||
throws an custom error that gets handled by the loading function
|
||||
"""
|
||||
try:
|
||||
cmd(*args, **kwargs)
|
||||
@ -592,5 +635,5 @@ registry = PluginsRegistry()
|
||||
|
||||
|
||||
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)
|
||||
|
@ -10,7 +10,22 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
||||
SLUG = "samplelabel"
|
||||
TITLE = "Sample Label Printer"
|
||||
DESCRIPTION = "A sample plugin which provides a (fake) label printer interface"
|
||||
VERSION = "0.1"
|
||||
VERSION = "0.2"
|
||||
|
||||
def print_label(self, label, **kwargs):
|
||||
print("OK PRINTING")
|
||||
def print_label(self, **kwargs):
|
||||
|
||||
# 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 -->
|
||||
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/inventree/inventree)
|
||||
![CI](https://github.com/inventree/inventree/actions/workflows/qc_checks.yaml/badge.svg)
|
||||
![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker_latest.yaml/badge.svg)
|
||||
![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker.yaml/badge.svg)
|
||||
|
||||
[![Coveralls](https://img.shields.io/coveralls/github/inventree/InvenTree)](https://coveralls.io/github/inventree/InvenTree)
|
||||
[![Crowdin](https://badges.crowdin.net/inventree/localized.svg)](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 re
|
||||
import sys
|
||||
@ -11,6 +22,14 @@ if __name__ == '__main__':
|
||||
|
||||
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 = None
|
||||
@ -30,66 +49,66 @@ if __name__ == '__main__':
|
||||
|
||||
print(f"InvenTree Version: '{version}'")
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('-t', '--tag', help='Compare against specified version tag', action='store')
|
||||
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')
|
||||
# Determine which docker tag we are going to use
|
||||
docker_tag = None
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
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")
|
||||
if GITHUB_REF_TYPE == 'branch' and ('stable' in GITHUB_REF or 'stable' in GITHUB_BASE_REF):
|
||||
print("Checking requirements for 'stable' release branch:")
|
||||
|
||||
pattern = r"^\d+(\.\d+)+$"
|
||||
|
||||
result = re.match(pattern, version)
|
||||
|
||||
if result is None:
|
||||
print(f"Version number '{version}' does not match required pattern for stable branch")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Version number '{version}' matches stable branch")
|
||||
|
||||
if args.tag:
|
||||
if args.tag != version:
|
||||
print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'")
|
||||
docker_tag = 'stable'
|
||||
|
||||
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)
|
||||
else:
|
||||
print(f"Version number '{version}' matches development branch")
|
||||
|
||||
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)
|
||||
|
||||
sys.exit(0)
|
||||
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
|
||||
# This directory specified where InvenTree source code is stored "outside" the docker containers
|
||||
# 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
|
||||
set -e
|
||||
|
||||
|
@ -16,6 +16,12 @@ INVENTREE_WEB_PORT=1337
|
||||
INVENTREE_DEBUG=False
|
||||
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
|
||||
# Note: The example setup is for a PostgreSQL database
|
||||
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-test-migrations==1.1.0 # Unit testing for database migrations
|
||||
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
|
||||
django-xforwardedfor-middleware==2.0 # IP forwarding metadata
|
||||
flake8==3.8.3 # PEP checking
|
||||
flake8-docstrings==1.6.0 # docstring format testing
|
||||
gunicorn>=20.1.0 # Gunicorn web server
|
||||
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
|
||||
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
|
||||
pre-commit==2.19.0 # Git pre-commit
|
||||
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
|
||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||
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
|
||||
|
4
tasks.py
4
tasks.py
@ -66,7 +66,7 @@ def plugins(c):
|
||||
print(f"Installing plugin packages from '{plugin_file}'")
|
||||
|
||||
# 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])
|
||||
@ -75,7 +75,7 @@ def install(c):
|
||||
print("Installing required python packages from 'requirements.txt'")
|
||||
|
||||
# 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
|
||||
|
Loading…
Reference in New Issue
Block a user