Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-05-29 14:28:55 +10:00
commit c0f3189041
68 changed files with 1673 additions and 1220 deletions

View File

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

6
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,6 @@
# General owner is the maintainers team
* @SchrodingersGat
# plugins are co-owned
/InvenTree/plugin/ @SchrodingersGat @matmair
/InvenTree/plugins/ @SchrodingersGat @matmair

17
.github/actions/migration/action.yaml vendored Normal file
View File

@ -0,0 +1,17 @@
name: 'Migration test'
description: 'Run migration test sequenze'
author: 'inventree'
runs:
using: 'composite'
steps:
- name: Data Import Export
shell: bash
run: |
invoke migrate
invoke import-fixtures
invoke export-records -f data.json
python3 ./InvenTree/manage.py flush --noinput
invoke migrate
invoke import-records -f data.json
invoke import-records -f data.json

82
.github/actions/setup/action.yaml vendored Normal file
View File

@ -0,0 +1,82 @@
name: 'Setup Enviroment'
description: 'Setup the enviroment for general InvenTree tests'
author: 'inventree'
inputs:
python:
required: false
description: 'Install python.'
default: 'true'
npm:
required: false
description: 'Install npm.'
default: 'false'
install:
required: false
description: 'Install the InvenTree requirements?'
default: 'false'
update:
required: false
description: 'Should a full update cycle be run?'
default: 'false'
apt-dependency:
required: false
description: 'Extra APT package for install.'
pip-dependency:
required: false
description: 'Extra python package for install.'
runs:
using: 'composite'
steps:
- name: Checkout Code
uses: actions/checkout@v2
# Python installs
- name: Set up Python ${{ env.python_version }}
if: ${{ inputs.python == 'true' }}
uses: actions/setup-python@v2
with:
python-version: ${{ env.python_version }}
cache: pip
- name: Install Base Python Dependencies
if: ${{ inputs.python == 'true' }}
shell: bash
run: |
python3 -m pip install -U pip
pip3 install invoke wheel
- name: Install Specific Python Dependencies
if: ${{ inputs.pip-dependency }}
shell: bash
run: pip3 install ${{ inputs.pip-dependency }}
# NPM installs
- name: Install node.js ${{ env.node_version }}
if: ${{ inputs.npm == 'true' }}
uses: actions/setup-node@v2
with:
node-version: ${{ env.node_version }}
cache: 'npm'
- name: Intall npm packages
if: ${{ inputs.npm == 'true' }}
shell: bash
run: npm install
# OS installs
- name: Install OS Dependencies
if: ${{ inputs.apt-dependency }}
shell: bash
run: |
sudo apt-get update
sudo apt-get install ${{ inputs.apt-dependency }}
# Invoke commands
- name: Run invoke install
if: ${{ inputs.install == 'true' }}
shell: bash
run: invoke install
- name: Run invoke update
if: ${{ inputs.update == 'true' }}
shell: bash
run: invoke update

69
.github/workflows/docker.yaml vendored Normal file
View 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 }}

View File

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

View File

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

View File

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

View File

@ -14,120 +14,97 @@ on:
env:
python_version: 3.9
node_version: 16
# The OS version must be set per job
server_start_sleep: 60
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: inventree
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
INVENTREE_MEDIA_ROOT: ../test_inventree_media
INVENTREE_STATIC_ROOT: ../test_inventree_static
jobs:
pep_style:
name: PEP style (python)
runs-on: ubuntu-latest
name: Style [Python]
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@v2
- uses: actions/checkout@v1
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install deps
run: |
pip install flake8==3.8.3
pip install pep8-naming==0.11.1
- name: flake8
run: |
flake8 InvenTree
install: true
- name: Run flake8
run: flake8 InvenTree --extend-ignore=D
javascript:
name: javascript template files
name: Style [JS]
runs-on: ubuntu-20.04
needs: pep_style
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install node.js ${{ env.node_version }}
uses: actions/setup-node@v2
- uses: actions/checkout@v1
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
node-version: ${{ env.node_version }}
cache: 'npm'
- run: npm install
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Check Templated Files
npm: true
install: true
- name: Check Templated JS Files
run: |
cd ci
python check_js_templates.py
python3 check_js_templates.py
- name: Lint Javascript Files
run: |
invoke render-js-files
npx eslint js_tmp/*.js
html:
name: html template files
name: Style [HTML]
runs-on: ubuntu-20.04
needs: pep_style
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install node.js ${{ env.node_version }}
uses: actions/setup-node@v2
- uses: actions/checkout@v1
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
node-version: ${{ env.node_version }}
cache: 'npm'
- run: npm install
- name: Setup Python
npm: true
install: true
- name: Check HTML Files
run: npx markuplint **/templates/*.html
pre-commit:
name: Style [pre-commit]
runs-on: ubuntu-20.04
needs: pep_style
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@v2
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
- name: Run pre-commit Checks
uses: pre-commit/action@v2.0.3
- name: Check version number
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Check HTML Files
run: |
npx markuplint InvenTree/build/templates/build/*.html
npx markuplint InvenTree/company/templates/company/*.html
npx markuplint InvenTree/order/templates/order/*.html
npx markuplint InvenTree/part/templates/part/*.html
npx markuplint InvenTree/stock/templates/stock/*.html
npx markuplint InvenTree/templates/*.html
npx markuplint InvenTree/templates/InvenTree/*.html
npx markuplint InvenTree/templates/InvenTree/settings/*.html
python3 ci/check_version_number.py
python:
name: python bindings
needs: pep_style
runs-on: ubuntu-latest
name: Tests - inventree-python
runs-on: ubuntu-20.04
needs: pre-commit
env:
wrapper_name: inventree-python
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
INVENTREE_MEDIA_ROOT: ../test_inventree_media
INVENTREE_STATIC_ROOT: ../test_inventree_static
INVENTREE_ADMIN_USER: testuser
INVENTREE_ADMIN_PASSWORD: testpassword
INVENTREE_ADMIN_EMAIL: test@test.com
@ -136,34 +113,32 @@ jobs:
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install InvenTree
run: |
sudo apt-get update
sudo apt-get install python3-dev python3-pip python3-venv
pip3 install invoke
invoke install
invoke migrate
- name: Download Python Code
run: |
git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }}
- name: Start Server
- uses: actions/checkout@v1
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
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 }}
- name: Start InvenTree Server
run: |
invoke delete-data -f
invoke import-fixtures
invoke server -a 127.0.0.1:12345 &
invoke wait
- name: Run Tests
- name: Run Tests For `${{ env.wrapper_name }}`
run: |
cd ${{ env.wrapper_name }}
invoke check-server
coverage run -m unittest discover -s test/
coverage:
name: Sqlite / coverage
needs: ['javascript', 'html']
runs-on: ubuntu-latest
name: Tests - DB [SQLite] + Coverage
runs-on: ubuntu-20.04
needs: ['javascript', 'html', 'pre-commit']
continue-on-error: true # continue if a step fails so that coverage gets pushed
env:
INVENTREE_DB_NAME: ./inventree.sqlite
@ -171,32 +146,16 @@ jobs:
INVENTREE_PLUGINS_ENABLED: true
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python ${{ env.python_version }}
uses: actions/setup-python@v2
- uses: actions/checkout@v1
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
python -m pip install -U pip
pip3 install invoke
invoke update
apt-dependency: gettext poppler-utils
update: true
- name: Coverage Tests
run: |
invoke coverage
- name: Data Import Export
run: |
invoke migrate
invoke import-fixtures
invoke export-records -f data.json
rm inventree.sqlite
invoke migrate
invoke import-records -f data.json
invoke import-records -f data.json
run: invoke coverage
- name: Data Export Test
uses: ./.github/actions/migration
- name: Test Translations
run: invoke translate
- name: Check Migration Files
@ -205,9 +164,10 @@ jobs:
run: coveralls
postgres:
name: Postgres
needs: ['javascript', 'html']
runs-on: ubuntu-latest
name: Tests - DB [PostgreSQL]
runs-on: ubuntu-20.04
needs: ['javascript', 'html', 'pre-commit']
if: github.event_name == 'push'
env:
@ -235,38 +195,23 @@ jobs:
- 6379:6379
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python ${{ env.python_version }}
uses: actions/setup-python@v2
- uses: actions/checkout@v1
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install libpq-dev gettext
python -m pip install -U pip
pip3 install invoke
pip3 install psycopg2
pip3 install django-redis>=5.0.0
invoke update
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg2 django-redis>=5.0.0
update: true
- name: Run Tests
run: invoke test
- name: Data Import Export
run: |
invoke migrate
python3 ./InvenTree/manage.py flush --noinput
invoke import-fixtures
invoke export-records -f data.json
python3 ./InvenTree/manage.py flush --noinput
invoke import-records -f data.json
invoke import-records -f data.json
- name: Data Export Test
uses: ./.github/actions/migration
mysql:
name: MySql
needs: ['javascript', 'html']
runs-on: ubuntu-latest
name: Tests - DB [MySQL]
runs-on: ubuntu-20.04
needs: ['javascript', 'html', 'pre-commit']
if: github.event_name == 'push'
env:
@ -293,29 +238,14 @@ jobs:
- 3306:3306
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python ${{ env.python_version }}
uses: actions/setup-python@v2
- uses: actions/checkout@v1
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install libmysqlclient-dev gettext
python -m pip install -U pip
pip3 install invoke
pip3 install mysqlclient
invoke update
apt-dependency: gettext poppler-utils libmysqlclient-dev
pip-dependency: mysqlclient
update: true
- name: Run Tests
run: invoke test
- name: Data Import Export
run: |
invoke migrate
python3 ./InvenTree/manage.py flush --noinput
invoke import-fixtures
invoke export-records -f data.json
python3 ./InvenTree/manage.py flush --noinput
invoke import-records -f data.json
invoke import-records -f data.json
- name: Data Export Test
uses: ./.github/actions/migration

View File

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

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

View File

@ -1,5 +1,10 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
exclude: |
(?x)^(
InvenTree/InvenTree/static/.*|
InvenTree/locale/.*
)$
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.2.0

View File

@ -2,7 +2,7 @@ Please read the contribution guidelines below, before submitting your first pull
## Setup
Please run `invoke setup_dev` in the root directory of your InvenTree code base to set up your development setup before starting to contribute. This will install and set up pre-commit to run some checks before each commit and help reduce the style errors.
Please run `invoke setup-dev` in the root directory of your InvenTree code base to set up your development setup before starting to contribute. This will install and set up pre-commit to run some checks before each commit and help reduce the style errors.
## Branches and Versioning

View File

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

View File

@ -117,6 +117,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

View File

@ -4,11 +4,14 @@ InvenTree API version information
# InvenTree API version
INVENTREE_API_VERSION = 50
INVENTREE_API_VERSION = 51
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v51 -> 2022-05-24 : https://github.com/inventree/InvenTree/pull/3058
- Adds new fields to the SalesOrderShipment model
v50 -> 2022-05-18 : https://github.com/inventree/InvenTree/pull/2912
- Implement Attachments for manufacturer parts

View File

@ -40,7 +40,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")

View File

@ -129,7 +129,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',

View File

@ -173,12 +173,9 @@ class StockStatus(StatusCode):
DESTROYED = 60 # Item is destroyed
REJECTED = 65 # Item is rejected
LOST = 70 # Item has been lost
QUARANTINED = 75 # Item has been quarantined and is unavailable
RETURNED = 85 # Item has been returned from a customer
# Any stock code above 100 means that the stock item is not "in stock"
# This can be used as a quick check for filtering
NOT_IN_STOCK = 100
options = {
OK: _("OK"),
ATTENTION: _("Attention needed"),
@ -186,6 +183,7 @@ class StockStatus(StatusCode):
DESTROYED: _("Destroyed"),
LOST: _("Lost"),
REJECTED: _("Rejected"),
QUARANTINED: _("Quarantined"),
RETURNED: _("Returned"),
}
@ -196,6 +194,7 @@ class StockStatus(StatusCode):
DESTROYED: 'danger',
LOST: 'dark',
REJECTED: 'danger',
QUARANTINED: 'info'
}
# The following codes correspond to parts that are 'available' or 'in stock'
@ -206,22 +205,6 @@ class StockStatus(StatusCode):
RETURNED,
]
# The following codes correspond to parts that are 'unavailable'
UNAVAILABLE_CODES = [
DESTROYED,
LOST,
REJECTED,
]
# The following codes are available for receiving goods
RECEIVING_CODES = [
OK,
ATTENTION,
DAMAGED,
DESTROYED,
REJECTED
]
class StockHistoryCode(StatusCode):

View File

@ -380,6 +380,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):
"""
@ -522,7 +546,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()

View File

@ -3,6 +3,7 @@ Version information for InvenTree.
Provides information on the current InvenTree version
"""
import os
import re
import subprocess
@ -12,7 +13,7 @@ import common.models
from InvenTree.api_version import INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = "0.7.0 dev"
INVENTREE_SW_VERSION = "0.8.0 dev"
def inventreeInstanceName():
@ -99,6 +100,12 @@ def inventreeDjangoVersion():
def inventreeCommitHash():
""" 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
@ -108,6 +115,12 @@ def inventreeCommitHash():
def inventreeCommitDate():
""" 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]

View File

@ -355,8 +355,9 @@ onPanelLoad('completed', function() {
onPanelLoad('children', function() {
loadBuildTable($('#sub-build-table'), {
url: '{% url "api-build-list" %}',
locale: '{{ request.LANGUAGE_CODE }}',
filterTarget: "#filter-list-sub-build",
parentBuild: {{ build.pk }},
params: {
ancestor: {{ build.pk }},
}
@ -527,11 +528,7 @@ $('#btn-unallocate').on('click', function() {
$('#allocate-selected-items').click(function() {
var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections");
if (bom_items.length == 0) {
bom_items = $("#allocation-table-untracked").bootstrapTable('getData');
}
var bom_items = getTableData('#allocation-table-untracked');
allocateStockToBuild(
{{ build.pk }},

View File

@ -40,13 +40,6 @@
</ul>
</div>
{% endif %}
<!-- Buttons to switch between list and calendar views -->
<button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
<span class='fas fa-calendar-alt'></span>
</button>
<button class='btn btn-outline-secondary' type='button' id='view-list' title='{% trans "Display list view" %}'>
<span class='fas fa-th-list'></span>
</button>
{% include "filter_list.html" with id="build" %}
</div>
</div>
@ -54,139 +47,24 @@
<table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>
</table>
<div id='build-order-calendar'></div>
</div>
{% endblock %}
{% block js_load %}
{{ block.super }}
<script type='text/javascript'>
function loadOrderEvents(calendar) {
var start = startDate(calendar);
var end = endDate(calendar);
clearEvents(calendar);
// Request build orders from the server within specified date range
inventreeGet(
'{% url "api-build-list" %}',
{
min_date: start,
max_date: end,
part_detail: true,
},
{
success: function(response) {
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) {
var order = response[idx];
var date = order.creation_date;
if (order.completion_date) {
date = order.completion_date;
} else if (order.target_date) {
date = order.target_date;
}
var title = `${prefix}${order.reference}`; //- ${order.quantity} x ${order.part_detail.name}`;
var color = '#4c68f5';
if (order.completed) {
color = '#25c234';
} else if (order.overdue) {
color = '#c22525';
}
var event = {
title: title,
start: date,
end: date,
url: `/build/${order.pk}/`,
backgroundColor: color,
};
calendar.addEvent(event);
}
}
}
);
}
var calendar = null;
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('build-order-calendar');
calendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth',
nowIndicator: true,
aspectRatio: 2.5,
locale: '{{request.LANGUAGE_CODE}}',
datesSet: function() {
loadOrderEvents(calendar);
}
});
calendar.render();
});
</script>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('#build-order-calendar').hide();
$('#view-list').hide();
$('#view-calendar').click(function() {
// Hide the list view, show the calendar view
$("#build-table").hide();
$("#view-calendar").hide();
$(".fixed-table-pagination").hide();
$(".columns-right").hide();
$(".search").hide();
$("#build-order-calendar").show();
$("#view-list").show();
calendar.render();
});
$("#view-list").click(function() {
// Hide the calendar view, show the list view
$("#build-order-calendar").hide();
$("#view-list").hide();
$(".fixed-table-pagination").show();
$(".columns-right").show();
$(".search").show();
$("#build-table").show();
$("#view-calendar").show();
});
$("#collapse-item-active").collapse().show();
$("#new-build").click(function() {
newBuildOrder();
});
loadBuildTable($("#build-table"), {
url: "{% url 'api-build-list' %}",
locale: '{{ request.LANGUAGE_CODE }}',
});
{% if report_enabled %}
$('#multi-build-print').click(function() {
var rows = $("#build-table").bootstrapTable('getSelections');
var rows = getTableData("#build-table");
var build_ids = [];
rows.forEach(function(row) {

View File

@ -203,7 +203,7 @@ class UIMessageNotification(SingleNotificationMethod):
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
"""

View File

@ -28,8 +28,8 @@
<div class='button-toolbar container-fluid'>
<div class='btn-group' role='group'>
<div class='btn-group'>
<button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-bs-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
<button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-bs-toggle="dropdown">
<span class='fas fa-tools'></span> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
@ -75,7 +75,8 @@
<div class='button-toolbar container-fluid'>
<div class='btn-group' role='group'>
<div class='btn-group' role='group'>
<button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-bs-toggle="dropdown">{% trans "Options" %}
<button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-bs-toggle="dropdown">
<span class='fas fa-tools'></span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
@ -307,20 +308,18 @@
}
);
linkButtonsToSelection($("#manufacturer-part-table"), ['#manufacturer-table-options']);
$("#multi-manufacturer-part-delete").click(function() {
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
var selections = getTableData('#manufacturer-part-table');
deleteManufacturerParts(selections, {
onSuccess: function() {
success: function() {
$("#manufacturer-part-table").bootstrapTable("refresh");
}
});
});
$("#multi-manufacturer-part-order").click(function() {
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
var selections = getTableData('#manufacturer-part-table');
var parts = [];
@ -364,34 +363,20 @@
}
);
linkButtonsToSelection($("#supplier-part-table"), ['#supplier-table-options']);
$("#multi-supplier-part-delete").click(function() {
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
var requests = [];
var selections = getTableData("#supplier-part-table");
showQuestionDialog(
'{% trans "Delete Supplier Parts?" %}',
'{% trans "All selected supplier parts will be deleted" %}',
{
accept: function() {
selections.forEach(function(part) {
var url = `/api/company/part/${part.pk}/`;
requests.push(inventreeDelete(url));
});
$.when.apply($, requests).done(function() {
deleteSupplierParts(selections, {
success: function() {
$('#supplier-part-table').bootstrapTable('refresh');
}
});
}
}
);
});
$("#multi-supplier-part-order").click(function() {
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
var selections = getTableData('#supplier-part-table');
var parts = [];

View File

@ -129,7 +129,9 @@ src="{% static 'img/blank_image.png' %}"
<div id='supplier-button-toolbar'>
<div class='btn-group'>
<div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<span class='fas fa-tools'></span> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a class='dropdown-item' href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}</a></li>
</ul>
@ -174,11 +176,14 @@ src="{% static 'img/blank_image.png' %}"
<div id='parameter-toolbar'>
<div class='btn-group'>
<div id='opt-dropdown' class="btn-group">
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<span class='fas fa-tools'></span> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a class='dropdown-item' href='#' id='multi-parameter-delete' title='{% trans "Delete parameters" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}</a></li>
</ul>
</div>
{% include "filter_list.html" with id="manufacturer-part-parameters" %}
</div>
</div>
@ -256,67 +261,22 @@ $('#supplier-create').click(function () {
$("#supplier-part-delete").click(function() {
var selections = $("#supplier-table").bootstrapTable("getSelections");
var selections = getTableData('#supplier-table');
var requests = [];
showQuestionDialog(
'{% trans "Delete Supplier Parts?" %}',
'{% trans "All selected supplier parts will be deleted" %}',
{
accept: function() {
selections.forEach(function(part) {
var url = `/api/company/part/${part.pk}/`;
requests.push(inventreeDelete(url));
deleteSupplierParts(selections, {
success: reloadSupplierPartTable,
});
$.when.apply($, requests).done(function() {
reloadSupplierPartTable();
});
}
}
);
});
$("#multi-parameter-delete").click(function() {
var selections = $("#parameter-table").bootstrapTable("getSelections");
var selections = getTableData('#parameter-table');
var text = `
<div class ='alert alert-block alert-danger'>
<p>{% trans "Selected parameters will be deleted" %}:</p>
<ul>`;
selections.forEach(function(item) {
text += `<li>${item.name} - <em>${item.value}</em></li>`;
});
text += `
</ul>
</div>`;
showQuestionDialog(
'{% trans "Delete Parameters" %}',
text,
{
accept_text: '{% trans "Delete" %}',
accept: function() {
// Delete each parameter via the API
var requests = [];
selections.forEach(function(item) {
var url = `/api/company/part/manufacturer/parameter/${item.pk}/`;
requests.push(inventreeDelete(url));
});
$.when.apply($, requests).done(function() {
deleteManufacturerPartParameters(selections, {
success: function() {
$('#parameter-table').bootstrapTable('refresh');
}
});
}
}
);
});
loadSupplierPartTable(
@ -326,9 +286,9 @@ loadSupplierPartTable(
params: {
part: {{ part.part.id }},
manufacturer_part: {{ part.id }},
part_detail: false,
part_detail: true,
supplier_detail: true,
manufacturer_detail: false,
manufacturer_detail: true,
},
}
);
@ -343,10 +303,6 @@ loadManufacturerPartParameterTable(
}
);
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']);
linkButtonsToSelection($("#parameter-table"), ['#parameter-options']);
$('#order-part, #order-part2').click(function() {
inventreeGet(
@ -376,15 +332,26 @@ $('#edit-part').click(function () {
$('#delete-part').click(function() {
deleteManufacturerPart({{ part.pk }}, {
onSuccess: function() {
inventreeGet(
'{% url "api-manufacturer-part-detail" part.pk %}',
{},
{
success: function(data) {
deleteManufacturerParts(
[data],
{
success: function() {
{% if part.manufacturer %}
window.location.href = "{% url 'company-detail' part.manufacturer.id %}";
{% else%}
window.location.href = "{% url 'index' %}";
{% endif %}
}
});
}
);
}
}
);
});
enableSidebar('manufacturerpart');

View File

@ -4,7 +4,7 @@
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label='parameters' text=text icon="fa-th-list" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
{% trans "Supplier Parts" as text %}
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}

View File

@ -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
@ -137,25 +134,21 @@ class LabelPrintMixin:
# Label instance
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
"""
# 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,
)

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.13 on 2022-05-24 05:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0068_alter_salesorderallocation_unique_together'),
]
operations = [
migrations.AddField(
model_name='salesordershipment',
name='invoice_number',
field=models.CharField(blank=True, help_text='Reference number for associated invoice', max_length=100, verbose_name='Invoice Number'),
),
migrations.AddField(
model_name='salesordershipment',
name='link',
field=models.URLField(blank=True, help_text='Link to external page', verbose_name='Link'),
),
]

View File

@ -4,7 +4,10 @@ Order model definitions
# -*- coding: utf-8 -*-
import logging
import os
import sys
import traceback
from datetime import datetime
from decimal import Decimal
@ -19,8 +22,10 @@ from django.dispatch.dispatcher import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from error_report.models import Error
from markdownx.models import MarkdownxField
from mptt.models import TreeForeignKey
@ -38,6 +43,8 @@ from plugin.models import MetadataMixin
from stock import models as stock_models
from users import models as UserModels
logger = logging.getLogger('inventree')
def get_next_po_number():
"""
@ -151,23 +158,74 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes'))
def get_total_price(self):
def get_total_price(self, target_currency=currency_code_default()):
"""
Calculates the total price of all order lines
Calculates the total price of all order lines, and converts to the specified target currency.
If not specified, the default system currency is used.
If currency conversion fails (e.g. there are no valid conversion rates),
then we simply return zero, rather than attempting some other calculation.
"""
target_currency = currency_code_default()
total = Money(0, target_currency)
# gather name reference
price_ref = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price'
# order items
total += sum(a.quantity * convert_money(getattr(a, price_ref), target_currency) for a in self.lines.all() if getattr(a, price_ref))
price_ref_tag = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price'
# extra lines
total += sum(a.quantity * convert_money(a.price, target_currency) for a in self.extra_lines.all() if a.price)
# order items
for line in self.lines.all():
price_ref = getattr(line, price_ref_tag)
if not price_ref:
continue
try:
total += line.quantity * convert_money(price_ref, target_currency)
except MissingRate:
# Record the error, try to press on
kind, info, data = sys.exc_info()
Error.objects.create(
kind=kind.__name__,
info=info,
data='\n'.join(traceback.format_exception(kind, info, data)),
path='order.get_total_price',
)
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# extra items
for line in self.extra_lines.all():
if not line.price:
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
except MissingRate:
# Record the error, try to press on
kind, info, data = sys.exc_info()
Error.objects.create(
kind=kind.__name__,
info=info,
data='\n'.join(traceback.format_exception(kind, info, data)),
path='order.get_total_price',
)
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# set decimal-places
total.decimal_places = 4
return total
@ -1202,6 +1260,20 @@ class SalesOrderShipment(models.Model):
help_text=_('Shipment tracking information'),
)
invoice_number = models.CharField(
max_length=100,
blank=True,
unique=False,
verbose_name=_('Invoice Number'),
help_text=_('Reference number for associated invoice'),
)
link = models.URLField(
blank=True,
verbose_name=_('Link'),
help_text=_('Link to external page')
)
def is_complete(self):
return self.shipment_date is not None
@ -1253,6 +1325,18 @@ class SalesOrderShipment(models.Model):
if tracking_number is not None:
self.tracking_number = tracking_number
# Was an invoice number provided?
invoice_number = kwargs.get('invoice_number', None)
if invoice_number is not None:
self.invoice_number = invoice_number
# Was a link provided?
link = kwargs.get('link', None)
if link is not None:
self.link = link
self.save()
trigger_event('salesordershipment.completed', id=self.pk)

View File

@ -886,6 +886,8 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
'checked_by',
'reference',
'tracking_number',
'invoice_number',
'link',
'notes',
]
@ -899,8 +901,10 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
model = order.models.SalesOrderShipment
fields = [
'tracking_number',
'shipment_date',
'tracking_number',
'invoice_number',
'link',
]
def validate(self, data):
@ -928,15 +932,14 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
request = self.context['request']
user = request.user
# Extract provided tracking number (optional)
tracking_number = data.get('tracking_number', shipment.tracking_number)
# Extract shipping date (defaults to today's date)
shipment_date = data.get('shipment_date', datetime.now())
shipment.complete_shipment(
user,
tracking_number=tracking_number,
tracking_number=data.get('tracking_number', shipment.tracking_number),
invoice_number=data.get('invoice_number', shipment.invoice_number),
link=data.get('link', shipment.link),
shipment_date=shipment_date,
)

View File

@ -181,7 +181,15 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total cost" %}</td>
<td id="poTotalPrice">{{ order.get_total_price }}</td>
<td id="poTotalPrice">
{% with order.get_total_price as tp %}
{% if tp == None %}
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
{% else %}
{{ tp }}
{% endif %}
{% endwith %}
</td>
</tr>
</table>
{% endblock %}

View File

@ -191,11 +191,7 @@ $('#new-po-line').click(function() {
{% elif order.status == PurchaseOrderStatus.PLACED %}
$('#receive-selected-items').click(function() {
var items = $("#po-line-table").bootstrapTable('getSelections');
if (items.length == 0) {
items = $("#po-line-table").bootstrapTable('getData');
}
var items = getTableData('#po-line-table');
receivePurchaseOrderItems(
{{ order.id }},

View File

@ -31,12 +31,6 @@
<span class='fas fa-print'></span>
</button>
{% endif %}
<button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
<span class='fas fa-calendar-alt'></span>
</button>
<button class='btn btn-outline-secondary' type='button' id='view-list' title='{% trans "Display list view" %}'>
<span class='fas fa-th-list'></span>
</button>
{% include "filter_list.html" with id="purchaseorder" %}
</div>
</div>
@ -54,125 +48,14 @@
{% block js_load %}
{{ block.super }}
<script type='text/javascript'>
function loadOrderEvents(calendar) {
var start = startDate(calendar);
var end = endDate(calendar);
clearEvents(calendar);
// Request purchase orders from the server within specified date range
inventreeGet(
'{% url "api-po-list" %}',
{
supplier_detail: true,
min_date: start,
max_date: end,
},
{
success: function(response) {
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) {
var order = response[idx];
var date = order.creation_date;
if (order.complete_date) {
date = order.complete_date;
} else if (order.target_date) {
date = order.target_date;
}
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;
var color = '#4c68f5';
if (order.complete_date) {
color = '#25c235';
} else if (order.overdue) {
color = '#c22525';
} else {
color = '#4c68f5';
}
var event = {
title: title,
start: date,
end: date,
url: `/order/purchase-order/${order.pk}/`,
backgroundColor: color,
};
calendar.addEvent(event);
}
}
}
);
}
var calendar = null;
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('purchase-order-calendar');
calendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth',
nowIndicator: true,
aspectRatio: 2.5,
locale: '{{request.LANGUAGE_CODE}}',
datesSet: function() {
loadOrderEvents(calendar);
}
});
calendar.render();
});
</script>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('#purchase-order-calendar').hide();
$('#view-list').hide();
$('#view-calendar').click(function() {
// Hide the list view, show the calendar view
$("#purchase-order-table").hide();
$("#view-calendar").hide();
$(".fixed-table-pagination").hide();
$(".columns-right").hide();
$(".search").hide();
$('#filter-list-salesorder').hide();
$("#purchase-order-calendar").show();
$("#view-list").show();
calendar.render();
});
$("#view-list").click(function() {
// Hide the calendar view, show the list view
$("#purchase-order-calendar").hide();
$("#view-list").hide();
$(".fixed-table-pagination").show();
$(".columns-right").show();
$(".search").show();
$("#purchase-order-table").show();
$('#filter-list-salesorder').show();
$("#view-calendar").show();
});
{% if report_enabled %}
$("#order-print").click(function() {
var rows = $("#purchase-order-table").bootstrapTable('getSelections');
var rows = getTableData('#purchase-order-table');
var orders = [];

View File

@ -188,7 +188,15 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total cost" %}</td>
<td id="soTotalPrice">{{ order.get_total_price }}</td>
<td id="soTotalPrice">
{% with order.get_total_price as tp %}
{% if tp == None %}
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
{% else %}
{{ tp }}
{% endif %}
{% endwith %}
</td>
</tr>
</table>
{% endblock %}

View File

@ -231,7 +231,7 @@
});
loadBuildTable($("#builds-table"), {
url: "{% url 'api-build-list' %}",
locale: '{{ request.LANGUAGE_CODE }}',
params: {
sales_order: {{ order.id }},
},

View File

@ -34,12 +34,6 @@
<span class='fas fa-print'></span>
</button>
{% endif %}
<button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
<span class='fas fa-calendar-alt'></span>
</button>
<button class='btn btn-outline-secondary' type='button' id='view-list' title='{% trans "Display list view" %}'>
<span class='fas fa-th-list'></span>
</button>
{% include "filter_list.html" with id="salesorder" %}
</div>
</div>
@ -53,130 +47,16 @@
{% endblock %}
{% block js_load %}
{{ block.super }}
<script type='text/javascript'>
function loadOrderEvents(calendar) {
var start = startDate(calendar);
var end = endDate(calendar);
clearEvents(calendar);
// Request orders from the server within specified date range
inventreeGet(
'{% url "api-so-list" %}',
{
customer_detail: true,
min_date: start,
max_date: end,
},
{
success: function(response) {
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) {
var order = response[idx];
var date = order.creation_date;
if (order.shipment_date) {
date = order.shipment_date;
} else if (order.target_date) {
date = order.target_date;
}
var title = `${prefix}${order.reference} - ${order.customer_detail.name}`;
// Default color is blue
var color = '#4c68f5';
// Overdue orders are red
if (order.overdue) {
color = '#c22525';
} else if (order.status == {{ SalesOrderStatus.SHIPPED }}) {
color = '#25c235';
}
var event = {
title: title,
start: date,
end: date,
url: `/order/sales-order/${order.pk}/`,
backgroundColor: color,
};
calendar.addEvent(event);
}
}
}
);
}
var calendar = null;
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('sales-order-calendar');
calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
nowIndicator: true,
aspectRatio: 2.5,
locale: '{{request.LANGUAGE_CODE}}',
datesSet: function() {
loadOrderEvents(calendar);
},
});
calendar.render();
});
</script>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#sales-order-calendar").hide();
$("#view-list").hide();
$('#view-calendar').click(function() {
// Hide the list view, show the calendar view
$("#sales-order-table").hide();
$("#view-calendar").hide();
$(".fixed-table-pagination").hide();
$(".columns-right").hide();
$(".search").hide();
$('#filter-list-salesorder').hide();
$("#sales-order-calendar").show();
$("#view-list").show();
calendar.render();
});
$("#view-list").click(function() {
// Hide the calendar view, show the list view
$("#sales-order-calendar").hide();
$("#view-list").hide();
$(".fixed-table-pagination").show();
$(".columns-right").show();
$(".search").show();
$("#sales-order-table").show();
$('#filter-list-salesorder').show();
$("#view-calendar").show();
});
loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
});
{% if report_enabled %}
$("#order-print").click(function() {
var rows = $("#sales-order-table").bootstrapTable('getSelections');
var rows = getTableData('#sales-order-table');
var orders = [];

View File

@ -1324,6 +1324,8 @@ class SalesOrderAllocateTest(OrderTest):
response = self.post(
url,
{
'invoice_number': 'INV01234',
'link': 'http://test.com/link.html',
'tracking_number': 'TRK12345',
'shipment_date': '2020-12-05',
},
@ -1334,6 +1336,8 @@ class SalesOrderAllocateTest(OrderTest):
self.assertTrue(self.shipment.is_complete())
self.assertEqual(self.shipment.tracking_number, 'TRK12345')
self.assertEqual(self.shipment.invoice_number, 'INV01234')
self.assertEqual(self.shipment.link, 'http://test.com/link.html')
def test_sales_order_shipment_list(self):

View File

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

View File

@ -360,7 +360,9 @@
<div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a class='dropdown-item' href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
<li><a class='dropdown-item' href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}
</a></li>
</ul>
</div>
{% include "filter_list.html" with id="supplier-part" %}
@ -443,27 +445,11 @@
$("#supplier-part-delete").click(function() {
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
var selections = getTableData('#supplier-part-table');
var requests = [];
showQuestionDialog(
'{% trans "Delete Supplier Parts?" %}',
'{% trans "All selected supplier parts will be deleted" %}',
{
accept: function() {
selections.forEach(function(part) {
var url = `/api/company/part/${part.pk}/`;
requests.push(inventreeDelete(url));
deleteSupplierParts(selections, {
success: reloadSupplierPartTable,
});
$.when.apply($, requests).done(function() {
reloadSupplierPartTable();
});
}
}
);
});
loadSupplierPartTable(
@ -472,7 +458,7 @@
{
params: {
part: {{ part.id }},
part_detail: false,
part_detail: true,
supplier_detail: true,
manufacturer_detail: true,
},
@ -497,10 +483,10 @@
$("#manufacturer-part-delete").click(function() {
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
var selectionss = getTableData('#manufacturer-part-table');
deleteManufacturerParts(selections, {
onSuccess: function() {
success: function() {
$("#manufacturer-part-table").bootstrapTable("refresh");
}
});
@ -528,7 +514,7 @@
});
loadBuildTable($("#build-table"), {
url: "{% url 'api-build-list' %}",
locale: '{{ request.LANGUAGE_CODE }}',
params: {
part: {{ part.id }},
}
@ -587,11 +573,7 @@
$('#bom-item-delete').click(function() {
// Get a list of the selected BOM items
var rows = $("#bom-table").bootstrapTable('getSelections');
if (rows.length == 0) {
rows = $('#bom-table').bootstrapTable('getData');
}
var rows = getTableData('#bom-table');
deleteBomItems(rows, {
success: function() {

View File

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

View File

@ -1,7 +1,14 @@
"""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
@ -9,7 +16,7 @@ from plugin.registry import registry
logger = logging.getLogger('inventree')
def print_label(plugin_slug, 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.
@ -19,10 +26,11 @@ def print_label(plugin_slug, label_image, label_instance=None, user=None):
Arguments:
plugin_slug: The unique slug (key) of the plugin
label_image: A PIL.Image image object to be printed
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, 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, 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

View File

@ -22,17 +22,18 @@ class LabelPrintingMixin:
super().__init__()
self.add_mixin('labels', True, __class__)
def print_label(self, label, **kwargs):
def print_label(self, **kwargs):
"""
Callback to print a single label
Arguments:
label: A black-and-white pillow Image object
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)

View File

@ -1,8 +1,11 @@
"""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
@ -68,7 +71,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
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"""
@ -167,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'

View File

@ -86,9 +86,11 @@ 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
@ -109,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
@ -123,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
@ -137,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)
@ -170,9 +176,11 @@ 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
@ -183,7 +191,7 @@ class PluginsRegistry:
with maintenance_mode_on():
self.unload_plugins()
self.load_plugins()
self.load_plugins(full_reload)
logger.info('Finished reloading plugins')
@ -335,12 +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()
@ -348,7 +356,7 @@ 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"""
@ -432,15 +440,15 @@ class PluginsRegistry:
"""
pass
def activate_plugin_app(self, plugins, force_reload=False):
"""
Activate AppMixin plugins - add custom apps and reload
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'):
@ -461,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()
@ -589,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

View File

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

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.13 on 2022-05-27 04:40
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0075_auto_20220515_1440'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'OK'), (50, 'Attention needed'), (55, 'Damaged'), (60, 'Destroyed'), (70, 'Lost'), (65, 'Rejected'), (75, 'Quarantined'), (85, 'Returned')], default=10, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@ -254,7 +254,7 @@
$('#multi-location-print-label').click(function() {
var selections = $('#sublocation-table').bootstrapTable('getSelections');
var selections = getTableData('#sublocation-table');
var locations = [];

View File

@ -223,7 +223,7 @@ addHeaderTitle('{% trans "Build Orders" %}');
{% if setting_build_pending %}
addHeaderAction('build-pending', '{% trans "Build Orders In Progress" %}', 'fa-cogs');
loadBuildTable("#table-build-pending", {
url: "{% url 'api-build-list' %}",
locale: '{{ request.LANGUAGE_CODE }}',
params: {
active: true,
},
@ -234,7 +234,7 @@ loadBuildTable("#table-build-pending", {
{% if setting_build_overdue %}
addHeaderAction('build-overdue', '{% trans "Overdue Build Orders" %}', 'fa-calendar-times');
loadBuildTable("#table-build-overdue", {
url: "{% url 'api-build-list' %}",
locale: '{{ request.LANGUAGE_CODE }}',
params: {
overdue: true,
},

View File

@ -139,6 +139,7 @@
addItem('build-order', '{% trans "Build Orders" %}', 'fa-tools');
loadBuildTable('#table-build-order', {
locale: '{{ request.LANGUAGE_CODE }}',
params: {
original_search: '{{ query }}',
}

View File

@ -7,6 +7,7 @@
/* exported
inventreeGet,
inventreeDelete,
inventreeMultiDelete,
inventreeFormDataUpload,
showApiError,
*/
@ -171,6 +172,50 @@ function inventreeDelete(url, options={}) {
return inventreePut(url, {}, options);
}
/*
* Perform a 'multi delete' operation:
*
* - Items are deleted sequentially from the database, rather than simultaneous requests
* - This prevents potential overload / transaction issues in the DB backend
*
* Notes:
* - Assumes that each item in the 'items' list has a parameter 'pk'
*/
function inventreeMultiDelete(url, items, options={}) {
if (!url.endsWith('/')) {
url += '/';
}
function doNextDelete() {
if (items.length > 0) {
var item = items.shift();
inventreeDelete(`${url}${item.pk}/`, {
complete: doNextDelete
});
} else {
if (options.modal) {
$(options.modal).modal('hide');
}
if (options.success) {
options.success();
}
}
}
if (options.modal) {
showModalSpinner(options.modal);
}
// Initiate the process
doNextDelete();
}
/*
* Display a notification with error information
*/

View File

@ -693,41 +693,20 @@ function deleteBomItems(items, options={}) {
`;
constructFormBody({}, {
method: 'DELETE',
title: '{% trans "Delete selected BOM items?" %}',
fields: {},
preFormContent: html,
submitText: '{% trans "Delete" %}',
submitClass: 'danger',
confirm: true,
onSubmit: function(fields, opts) {
// Individually send DELETE requests for each BOM item
// We do *not* send these all at once, to prevent overloading the server
// Show the progress spinner
$(opts.modal).find('#modal-progress-spinner').show();
function deleteNextBomItem() {
if (items.length > 0) {
var item = items.shift();
inventreeDelete(`/api/bom/${item.pk}/`,
inventreeMultiDelete(
'{% url "api-bom-list" %}',
items,
{
complete: deleteNextBomItem,
modal: opts.modal,
success: options.success,
}
);
} else {
// Destroy this modal once all items are deleted
$(opts.modal).modal('hide');
if (options.success) {
options.success();
}
}
}
deleteNextBomItem();
},
});
}

View File

@ -834,12 +834,7 @@ function loadBuildOutputTable(build_info, options={}) {
var subtable = $(`#output-sub-table-${pk}`);
if (subtable.exists()) {
var rows = subtable.bootstrapTable('getSelections');
// None selected? Use all!
if (rows.length == 0) {
rows = subtable.bootstrapTable('getData');
}
var rows = getTableData(`#output-sub-table-${pk}`);
allocateStockToBuild(
build_info.pk,
@ -1291,11 +1286,7 @@ function loadBuildOutputTable(build_info, options={}) {
// Complete multiple outputs
$('#multi-output-complete').click(function() {
var outputs = $(table).bootstrapTable('getSelections');
if (outputs.length == 0) {
outputs = $(table).bootstrapTable('getData');
}
var outputs = getTableData(table);
completeBuildOutputs(
build_info.pk,
@ -1314,11 +1305,7 @@ function loadBuildOutputTable(build_info, options={}) {
// Delete multiple build outputs
$('#multi-output-delete').click(function() {
var outputs = $(table).bootstrapTable('getSelections');
if (outputs.length == 0) {
outputs = $(table).bootstrapTable('getData');
}
var outputs = getTableData(table);
deleteBuildOutputs(
build_info.pk,
@ -1337,11 +1324,7 @@ function loadBuildOutputTable(build_info, options={}) {
// Print stock item labels
$('#incomplete-output-print-label').click(function() {
var outputs = $(table).bootstrapTable('getSelections');
if (outputs.length == 0) {
outputs = $(table).bootstrapTable('getData');
}
var outputs = getTableData(table);
var stock_id_values = [];
@ -2337,6 +2320,9 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
*/
function loadBuildTable(table, options) {
// Ensure the table starts in a known state
$(table).bootstrapTable('destroy');
var params = options.params || {};
var filters = {};
@ -2351,23 +2337,105 @@ function loadBuildTable(table, options) {
filters[key] = params[key];
}
options.url = options.url || '{% url "api-build-list" %}';
var filterTarget = options.filterTarget || null;
setupFilterList('build', table, filterTarget, {download: true});
// Which display mode to use for the build table?
var display_mode = inventreeLoad('build-table-display-mode', 'list');
var tree_enable = display_mode == 'tree';
var loaded_calendar = false;
// Function for rendering BuildOrder calendar display
function buildEvents(calendar) {
var start = startDate(calendar);
var end = endDate(calendar);
clearEvents(calendar);
// Extract current filters from table
var table_options = $(table).bootstrapTable('getOptions');
var filters = table_options.query_params || {};
filters.min_date = start;
filters.max_date = end;
filters.part_detail = true;
// Request build orders from the server within specified date range
inventreeGet(
'{% url "api-build-list" %}',
filters,
{
success: function(response) {
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) {
var order = response[idx];
var date = order.creation_date;
if (order.completion_date) {
date = order.completion_date;
} else if (order.target_date) {
date = order.target_date;
}
var title = `${prefix}${order.reference}`;
var color = '#4c68f5';
if (order.completed) {
color = '#25c234';
} else if (order.overdue) {
color = '#c22525';
}
var event = {
title: title,
start: date,
end: date,
url: `/build/${order.pk}/`,
backgroundColor: color,
};
calendar.addEvent(event);
}
}
}
);
}
$(table).inventreeTable({
method: 'get',
formatNoMatches: function() {
return '{% trans "No builds matching query" %}';
},
url: options.url,
url: '{% url "api-build-list" %}',
queryParams: filters,
groupBy: false,
sidePagination: 'server',
name: 'builds',
original: params,
treeEnable: tree_enable,
uniqueId: 'pk',
rootParentId: options.parentBuild || null,
idField: 'pk',
parentIdField: 'parent',
treeShowField: tree_enable ? 'reference' : null,
showColumns: display_mode == 'list' || display_mode == 'tree',
showCustomView: display_mode == 'calendar',
showCustomViewButton: false,
disablePagination: display_mode == 'calendar',
search: display_mode != 'calendar',
buttons: constructOrderTableButtons({
prefix: 'build',
callback: function() {
// Force complete reload of the table
loadBuildTable(table, options);
}
}),
columns: [
{
field: 'pk',
@ -2494,6 +2562,43 @@ function loadBuildTable(table, options) {
}
},
],
customView: function(data) {
return `<div id='build-order-calendar'></div>`;
},
onRefresh: function() {
loadBuildTable(table, options);
},
onLoadSuccess: function() {
if (tree_enable) {
$(table).treegrid({
treeColumn: 1,
});
table.treegrid('expandAll');
} else if (display_mode == 'calendar') {
if (!loaded_calendar) {
loaded_calendar = true;
var el = document.getElementById('build-order-calendar');
calendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth',
nowIndicator: true,
aspectRatio: 2.5,
locale: options.locale,
datesSet: function() {
buildEvents(calendar);
}
});
calendar.render();
} else {
calendar.render();
}
}
}
});
linkButtonsToSelection(

View File

@ -3,12 +3,11 @@
/* globals
constructForm,
imageHoverIcon,
inventreeDelete,
inventreeMultiDelete,
loadTableFilters,
makeIconButton,
renderLink,
setupFilterList,
showQuestionDialog,
*/
/* exported
@ -16,6 +15,8 @@
createManufacturerPart,
createSupplierPart,
deleteManufacturerParts,
deleteManufacturerPartParameters,
deleteSupplierParts,
editCompany,
loadCompanyTable,
loadManufacturerPartTable,
@ -101,15 +102,6 @@ function editManufacturerPart(part, options={}) {
});
}
function deleteManufacturerPart(part, options={}) {
constructForm(`/api/company/part/manufacturer/${part}/`, {
method: 'DELETE',
title: '{% trans "Delete Manufacturer Part" %}',
onSuccess: options.onSuccess,
});
}
function supplierPartFields() {
@ -211,12 +203,76 @@ function editSupplierPart(part, options={}) {
}
function deleteSupplierPart(part, options={}) {
/*
* Delete one or more SupplierPart objects from the database.
* - User will be provided with a modal form, showing all the parts to be deleted.
* - Delete operations are performed sequentialy, not simultaneously
*/
function deleteSupplierParts(parts, options={}) {
constructForm(`/api/company/part/${part}/`, {
if (parts.length == 0) {
return;
}
function renderPart(sup_part) {
var part = sup_part.part_detail;
var thumb = thumbnailImage(part.thumbnail || part.image);
var supplier = '-';
var MPN = '-';
if (sup_part.supplier_detail) {
supplier = sup_part.supplier_detail.name;
}
if (sup_part.manufacturer_part_detail) {
MPN = sup_part.manufacturer_part_detail.MPN;
}
return `
<tr>
<td>${thumb} ${part.full_name}</td>
<td>${sup_part.SKU}</td>
<td>${supplier}</td>
<td>${MPN}</td>
</tr>`;
}
var rows = '';
parts.forEach(function(sup_part) {
rows += renderPart(sup_part);
});
var html = `
<div class='alert alert-block alert-danger'>
{% trans "All selected supplier parts will be deleted" %}
</div>
<table class='table table-striped table-condensed'>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "SKU" %}</th>
<th>{% trans "Supplier" %}</th>
<th>{% trans "MPN" %}</th>
</tr>
${rows}
</table>
`;
constructFormBody({}, {
method: 'DELETE',
title: '{% trans "Delete Supplier Part" %}',
onSuccess: options.onSuccess,
title: '{% trans "Delete Supplier Parts" %}',
preFormContent: html,
onSubmit: function(fields, opts) {
inventreeMultiDelete(
'{% url "api-supplier-part-list" %}',
parts,
{
modal: opts.modal,
success: options.success
}
);
}
});
}
@ -393,58 +449,116 @@ function loadCompanyTable(table, url, options={}) {
}
/* Delete one or more ManufacturerPart objects from the database.
* - User will be provided with a modal form, showing all the parts to be deleted.
* - Delete operations are performed sequentialy, not simultaneously
*/
function deleteManufacturerParts(selections, options={}) {
if (selections.length == 0) {
return;
}
var parts = [];
function renderPart(man_part, opts={}) {
var part = man_part.part_detail;
var thumb = thumbnailImage(part.thumbnail || part.image);
var text = `
return `
<tr>
<td>${thumb} ${part.full_name}</td>
<td>${man_part.MPN}</td>
<td>${man_part.manufacturer_detail.name}</td>
</tr>`;
}
var rows = '';
selections.forEach(function(man_part) {
rows += renderPart(man_part);
});
var html = `
<div class='alert alert-block alert-danger'>
<p>{% trans "The following manufacturer parts will be deleted" %}:</p>
<ul>`;
{% trans "All selected manufacturer parts will be deleted" %}
</div>
<table class='table table-striped table-condensed'>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "MPN" %}</th>
<th>{% trans "Manufacturer" %}</th>
</tr>
${rows}
</table>
`;
selections.forEach(function(item) {
parts.push(item.pk);
constructFormBody({}, {
method: 'DELETE',
title: '{% trans "Delete Manufacturer Parts" %}',
preFormContent: html,
onSubmit: function(fields, opts) {
text += `
<li>
<p>${item.MPN} - ${item.part_detail.full_name}</p>
</li>`;
});
text += `
</ul>
</div>`;
showQuestionDialog(
'{% trans "Delete Manufacturer Parts" %}',
text,
inventreeMultiDelete(
'{% url "api-manufacturer-part-list" %}',
selections,
{
accept_text: '{% trans "Delete" %}',
accept: function() {
// Delete each manufacturer part
var requests = [];
parts.forEach(function(pk) {
var url = `/api/company/part/manufacturer/${pk}`;
requests.push(inventreeDelete(url));
});
// Wait for all the requests to complete
$.when.apply($, requests).done(function() {
if (options.onSuccess) {
options.onSuccess();
}
});
}
modal: opts.modal,
success: options.success,
}
);
}
});
}
function deleteManufacturerPartParameters(selections, options={}) {
if (selections.length == 0) {
return;
}
function renderParam(param) {
return `
<tr>
<td>${param.name}</td>
<td>${param.units}</td>
</tr>`;
}
var rows = '';
selections.forEach(function(param) {
rows += renderParam(param);
});
var html = `
<div class='alert alert-block alert-danger'>
{% trans "All selected parameters will be deleted" %}
</div>
<table class='table table-striped table-condensed'>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Value" %}</th>
</tr>
${rows}
</table>
`;
constructFormBody({}, {
method: 'DELETE',
title: '{% trans "Delete Parameters" %}',
preFormContent: html,
onSubmit: function(fields, opts) {
inventreeMultiDelete(
'{% url "api-manufacturer-part-parameter-list" %}',
selections,
{
modal: opts.modal,
success: options.success,
}
);
}
});
}
@ -473,6 +587,7 @@ function loadManufacturerPartTable(table, url, options) {
method: 'get',
original: params,
queryParams: filters,
uniqueId: 'pk',
sidePagination: 'server',
name: 'manufacturerparts',
groupBy: false,
@ -588,11 +703,12 @@ function loadManufacturerPartTable(table, url, options) {
$(table).find('.button-manufacturer-part-delete').click(function() {
var pk = $(this).attr('pk');
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
deleteManufacturerPart(
pk,
deleteManufacturerParts(
[row],
{
onSuccess: function() {
success: function() {
$(table).bootstrapTable('refresh');
}
}
@ -618,7 +734,7 @@ function loadManufacturerPartParameterTable(table, url, options) {
filters[key] = params[key];
}
// setupFilterList("manufacturer-part-parameters", $(table));
setupFilterList('manufacturer-part-parameters', $(table));
$(table).inventreeTable({
url: url,
@ -730,6 +846,7 @@ function loadSupplierPartTable(table, url, options) {
method: 'get',
original: params,
sidePagination: 'server',
uniqueId: 'pk',
queryParams: filters,
name: 'supplierparts',
groupBy: false,
@ -886,11 +1003,12 @@ function loadSupplierPartTable(table, url, options) {
$(table).find('.button-supplier-part-delete').click(function() {
var pk = $(this).attr('pk');
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
deleteSupplierPart(
pk,
deleteSupplierParts(
[row],
{
onSuccess: function() {
success: function() {
$(table).bootstrapTable('refresh');
}
}

View File

@ -247,11 +247,6 @@ function constructChangeForm(fields, options) {
*/
function constructDeleteForm(fields, options) {
// Force the "confirm" property if not set
if (!('confirm' in options)) {
options.confirm = true;
}
// Request existing data from the API endpoint
// This data can be used to render some information on the form
$.ajax({
@ -430,6 +425,21 @@ function constructFormBody(fields, options) {
// otherwise *all* fields will be displayed
var displayed_fields = options.fields || fields;
// Override default option values if a 'DELETE' form is specified
if (options.method == 'DELETE') {
if (!('confirm' in options)) {
options.confirm = true;
}
if (!('submitClass' in options)) {
options.submitClass = 'danger';
}
if (!('submitText' in options)) {
options.submitText = '{% trans "Delete" %}';
}
}
// Handle initial data overrides
if (options.data) {
for (const field in options.data) {
@ -797,7 +807,7 @@ function submitFormData(fields, options) {
}
// Show the progress spinner
$(options.modal).find('#modal-progress-spinner').show();
showModalSpinner(options.modal);
// Submit data
upload_func(
@ -2625,7 +2635,7 @@ function selectImportFields(url, data={}, options={}) {
columns.push(getFormFieldValue(`column_${idx}`, {}, opts));
}
$(opts.modal).find('#modal-progress-spinner').show();
showModalSpinner(opts.modal);
inventreePut(
opts.url,

View File

@ -16,6 +16,7 @@
showModalImage,
removeRowFromModalForm,
showQuestionDialog,
showModalSpinner,
*/
/*
@ -1146,3 +1147,13 @@ function showModalImage(image_url) {
hideModalImage();
});
}
/* Show (or hide) a progress spinner icon in the dialog */
function showModalSpinner(modal, show=true) {
if (show) {
$(modal).find('#modal-progress-spinner').show();
} else {
$(modal).find('#modal-progress-spinner').hide();
}
}

View File

@ -55,6 +55,12 @@ function salesOrderShipmentFields(options={}) {
tracking_number: {
icon: 'fa-hashtag',
},
invoice_number: {
icon: 'fa-dollar-sign',
},
link: {
icon: 'fa-link',
}
};
// If order is specified, hide the order field
@ -129,11 +135,20 @@ function completeShipment(shipment_id, options={}) {
method: 'POST',
title: `{% trans "Complete Shipment" %} ${shipment.reference}`,
fields: {
tracking_number: {
value: shipment.tracking_number,
},
shipment_date: {
value: moment().format('YYYY-MM-DD'),
},
tracking_number: {
value: shipment.tracking_number,
icon: 'fa-hashtag',
},
invoice_number: {
value: shipment.invoice_number,
icon: 'fa-dollar-sign',
},
link: {
value: shipment.link,
icon: 'fa-link',
}
},
preFormContent: html,
@ -1489,7 +1504,8 @@ function removePurchaseOrderLineItem(e) {
* Load a table displaying list of purchase orders
*/
function loadPurchaseOrderTable(table, options) {
/* Create a purchase-order table */
// Ensure the table starts in a known state
$(table).bootstrapTable('destroy');
options.params = options.params || {};
@ -1505,6 +1521,71 @@ function loadPurchaseOrderTable(table, options) {
setupFilterList('purchaseorder', $(table), target, {download: true});
var display_mode = inventreeLoad('purchaseorder-table-display-mode', 'list');
// Function for rendering PurchaseOrder calendar display
function buildEvents(calendar) {
var start = startDate(calendar);
var end = endDate(calendar);
clearEvents(calendar);
// Extract current filters from table
var table_options = $(table).bootstrapTable('getOptions');
var filters = table_options.query_params || {};
filters.supplier_detail = true;
filters.min_date = start;
filters.max_date = end;
// Request purchase orders from the server within specified date range
inventreeGet(
'{% url "api-po-list" %}',
filters,
{
success: function(response) {
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) {
var order = response[idx];
var date = order.creation_date;
if (order.complete_date) {
date = order.complete_date;
} else if (order.target_date) {
date = order.target_date;
}
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;
var color = '#4c68f5';
if (order.complete_date) {
color = '#25c235';
} else if (order.overdue) {
color = '#c22525';
} else {
color = '#4c68f5';
}
var event = {
title: title,
start: date,
end: date,
url: `/order/purchase-order/${order.pk}/`,
backgroundColor: color,
};
calendar.addEvent(event);
}
}
}
);
}
$(table).inventreeTable({
url: '{% url "api-po-list" %}',
queryParams: filters,
@ -1512,9 +1593,22 @@ function loadPurchaseOrderTable(table, options) {
groupBy: false,
sidePagination: 'server',
original: options.params,
showColumns: display_mode == 'list',
disablePagination: display_mode == 'calendar',
showCustomViewButton: false,
showCustomView: display_mode == 'calendar',
search: display_mode != 'calendar',
formatNoMatches: function() {
return '{% trans "No purchase orders found" %}';
},
buttons: constructOrderTableButtons({
prefix: 'purchaseorder',
disableTreeView: true,
callback: function() {
// Reload the entire table
loadPurchaseOrderTable(table, options);
}
}),
columns: [
{
title: '',
@ -1614,6 +1708,30 @@ function loadPurchaseOrderTable(table, options) {
}
},
],
customView: function(data) {
return `<div id='purchase-order-calendar'></div>`;
},
onRefresh: function() {
loadPurchaseOrderTable(table, options);
},
onLoadSuccess: function() {
if (display_mode == 'calendar') {
var el = document.getElementById('purchase-order-calendar');
calendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth',
nowIndicator: true,
aspectRatio: 2.5,
locale: options.locale,
datesSet: function() {
buildEvents(calendar);
}
});
calendar.render();
}
}
});
}
@ -2176,6 +2294,9 @@ function loadPurchaseOrderExtraLineTable(table, options={}) {
*/
function loadSalesOrderTable(table, options) {
// Ensure the table starts in a known state
$(table).bootstrapTable('destroy');
options.params = options.params || {};
options.params['customer_detail'] = true;
@ -2191,6 +2312,70 @@ function loadSalesOrderTable(table, options) {
setupFilterList('salesorder', $(table), target, {download: true});
var display_mode = inventreeLoad('salesorder-table-display-mode', 'list');
function buildEvents(calendar) {
var start = startDate(calendar);
var end = endDate(calendar);
clearEvents(calendar);
// Extract current filters from table
var table_options = $(table).bootstrapTable('getOptions');
var filters = table_options.query_params || {};
filters.customer_detail = true;
filters.min_date = start;
filters.max_date = end;
// Request orders from the server within specified date range
inventreeGet(
'{% url "api-so-list" %}',
filters,
{
success: function(response) {
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) {
var order = response[idx];
var date = order.creation_date;
if (order.shipment_date) {
date = order.shipment_date;
} else if (order.target_date) {
date = order.target_date;
}
var title = `${prefix}${order.reference} - ${order.customer_detail.name}`;
// Default color is blue
var color = '#4c68f5';
// Overdue orders are red
if (order.overdue) {
color = '#c22525';
} else if (order.status == {{ SalesOrderStatus.SHIPPED }}) {
color = '#25c235';
}
var event = {
title: title,
start: date,
end: date,
url: `/order/sales-order/${order.pk}/`,
backgroundColor: color,
};
calendar.addEvent(event);
}
}
}
);
}
$(table).inventreeTable({
url: options.url,
queryParams: filters,
@ -2198,9 +2383,46 @@ function loadSalesOrderTable(table, options) {
groupBy: false,
sidePagination: 'server',
original: options.params,
showColums: display_mode != 'calendar',
search: display_mode != 'calendar',
showCustomViewButton: false,
showCustomView: display_mode == 'calendar',
disablePagination: display_mode == 'calendar',
formatNoMatches: function() {
return '{% trans "No sales orders found" %}';
},
buttons: constructOrderTableButtons({
prefix: 'salesorder',
disableTreeView: true,
callback: function() {
// Reload the entire table
loadSalesOrderTable(table, options);
},
}),
customView: function(data) {
return `<div id='purchase-order-calendar'></div>`;
},
onRefresh: function() {
loadPurchaseOrderTable(table, options);
},
onLoadSuccess: function() {
if (display_mode == 'calendar') {
var el = document.getElementById('purchase-order-calendar');
calendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth',
nowIndicator: true,
aspectRatio: 2.5,
locale: options.locale,
datesSet: function() {
buildEvents(calendar);
}
});
calendar.render();
}
},
columns: [
{
title: '',
@ -2445,10 +2667,26 @@ function loadSalesOrderShipmentTable(table, options={}) {
field: 'tracking_number',
title: '{% trans "Tracking" %}',
},
{
field: 'invoice_number',
title: '{% trans "Invoice" %}',
},
{
field: 'link',
title: '{% trans "Link" %}',
formatter: function(value) {
if (value) {
return renderLink(value, value);
} else {
return '-';
}
}
},
{
field: 'notes',
title: '{% trans "Notes" %}',
visible: false,
switchable: false,
// TODO: Implement 'notes' field
},
{

View File

@ -1560,7 +1560,7 @@ function loadPartTable(table, url, options={}) {
/* Button callbacks for part table buttons */
$('#multi-part-order').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var selections = getTableData(table);
var parts = [];
@ -1594,7 +1594,7 @@ function loadPartTable(table, url, options={}) {
});
$('#multi-part-print-label').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var selections = getTableData(table);
var items = [];

View File

@ -17,6 +17,41 @@ function closeSearchPanel() {
}
// Keep track of the roles / permissions available to the current user
var search_user_roles = null;
/*
* Check if the user has the specified role and permission
*/
function checkPermission(role, permission='view') {
if (!search_user_roles) {
return false;
}
if (!(role in search_user_roles)) {
return false;
}
var roles = search_user_roles[role];
if (!roles) {
return false;
}
var found = false;
search_user_roles[role].forEach(function(p) {
if (String(p).valueOf() == String(permission).valueOf()) {
found = true;
}
});
return found;
}
/*
* Callback when the search panel is opened.
* Ensure the panel is in a known state
@ -27,6 +62,16 @@ function openSearchPanel() {
clearSearchResults();
// Request user roles if we do not have them
if (search_user_roles == null) {
inventreeGet('{% url "api-user-roles" %}', {}, {
success: function(response) {
search_user_roles = response.roles || {};
}
});
}
// Callback for text input changed
panel.find('#search-input').on('keyup change', searchTextChanged);
// Callback for "clear search" button
@ -84,7 +129,7 @@ function updateSearch() {
// Show the "searching" text
$('#offcanvas-search').find('#search-pending').show();
if (user_settings.SEARCH_PREVIEW_SHOW_PARTS) {
if (checkPermission('part') && user_settings.SEARCH_PREVIEW_SHOW_PARTS) {
var params = {};
@ -106,7 +151,7 @@ function updateSearch() {
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) {
if (checkPermission('part_category') && user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) {
// Search for matching part categories
addSearchQuery(
'category',
@ -120,7 +165,7 @@ function updateSearch() {
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_STOCK) {
if (checkPermission('stock') && user_settings.SEARCH_PREVIEW_SHOW_STOCK) {
// Search for matching stock items
var filters = {
@ -146,7 +191,7 @@ function updateSearch() {
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) {
if (checkPermission('stock_location') && user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) {
// Search for matching stock locations
addSearchQuery(
'location',
@ -160,7 +205,7 @@ function updateSearch() {
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) {
if ((checkPermission('sales_order') || checkPermission('purchase_order')) && user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) {
// Search for matching companies
addSearchQuery(
'company',
@ -174,7 +219,7 @@ function updateSearch() {
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) {
if (checkPermission('purchase_order') && user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) {
var filters = {
supplier_detail: true,
@ -197,7 +242,7 @@ function updateSearch() {
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) {
if (checkPermission('sales_order') && user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) {
var filters = {
customer_detail: true,

View File

@ -11,8 +11,8 @@
global_settings,
handleFormErrors,
imageHoverIcon,
inventreeDelete,
inventreeGet,
inventreeMultiDelete,
inventreePut,
launchModalForm,
linkButtonsToSelection,
@ -1106,25 +1106,15 @@ function adjustStock(action, items, options={}) {
// Delete action is handled differently
if (action == 'delete') {
var requests = [];
item_pk_values.forEach(function(pk) {
requests.push(
inventreeDelete(
`/api/stock/${pk}/`,
)
);
});
// Wait for *all* the requests to complete
$.when.apply($, requests).done(function() {
// Destroy the modal window
$(opts.modal).modal('hide');
if (options.success) {
options.success();
inventreeMultiDelete(
'{% url "api-stock-list" %}',
items,
{
modal: opts.modal,
success: options.success,
}
});
);
return;
}
@ -1955,7 +1945,7 @@ function loadStockTable(table, options) {
);
function stockAdjustment(action) {
var items = $(table).bootstrapTable('getSelections');
var items = getTableData(table);
adjustStock(action, items, {
success: function() {
@ -1967,7 +1957,7 @@ function loadStockTable(table, options) {
// Automatically link button callbacks
$('#multi-item-print-label').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var selections = getTableData(table);
var items = [];
@ -1979,7 +1969,7 @@ function loadStockTable(table, options) {
});
$('#multi-item-print-test-report').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var selections = getTableData(table);
var items = [];
@ -1992,7 +1982,7 @@ function loadStockTable(table, options) {
if (global_settings.BARCODE_ENABLE) {
$('#multi-item-barcode-scan-into-location').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var selections = getTableData(table);
var items = [];
@ -2021,7 +2011,7 @@ function loadStockTable(table, options) {
});
$('#multi-item-merge').click(function() {
var items = $(table).bootstrapTable('getSelections');
var items = getTableData(table);
mergeStockItems(items, {
success: function(response) {
@ -2036,7 +2026,7 @@ function loadStockTable(table, options) {
$('#multi-item-assign').click(function() {
var items = $(table).bootstrapTable('getSelections');
var items = getTableData(table);
assignStockToCustomer(items, {
success: function() {
@ -2046,7 +2036,8 @@ function loadStockTable(table, options) {
});
$('#multi-item-order').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var selections = getTableData(table);
var parts = [];
@ -2063,7 +2054,7 @@ function loadStockTable(table, options) {
$('#multi-item-set-status').click(function() {
// Select and set the STATUS field for selected stock items
var selections = $(table).bootstrapTable('getSelections');
var selections = getTableData(table);
// Select stock status
var modal = '#modal-form';
@ -2149,7 +2140,7 @@ function loadStockTable(table, options) {
});
$('#multi-item-delete').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var selections = getTableData(table);
var stock = [];

View File

@ -8,9 +8,11 @@
/* exported
customGroupSorter,
downloadTableData,
getTableData,
reloadtable,
renderLink,
reloadTableFilters,
constructOrderTableButtons,
*/
/**
@ -22,6 +24,96 @@ function reloadtable(table) {
}
/*
* Construct a set of extra buttons to display against a list of orders,
* allowing the orders to be displayed in various 'view' modes:
*
* - Calendar view
* - List view
* - Tree view
*
* Options:
* - callback: Callback function to be called when one of the buttons is pressed
* - prefix: The prefix to use when saving display data to user session
* - display: Which button to set as 'active' by default
*
*/
function constructOrderTableButtons(options={}) {
var display_mode = options.display;
var key = `${options.prefix || order}-table-display-mode`;
// If display mode is not provided, look up from session
if (!display_mode) {
display_mode = inventreeLoad(key, 'list');
}
var idx = 0;
var buttons = [];
function buttonCallback(view_mode) {
inventreeSave(key, view_mode);
if (options.callback) {
options.callback(view_mode);
}
}
var class_calendar = display_mode == 'calendar' ? 'btn-secondary' : 'btn-outline-secondary';
var class_list = display_mode == 'list' ? 'btn-secondary' : 'btn-outline-secondary';
var class_tree = display_mode == 'tree' ? 'btn-secondary' : 'btn-outline-secondary';
// Calendar view button
if (!options.disableCalendarView) {
buttons.push({
html: `<button type='button' name='${idx++}' class='btn ${class_calendar}' title='{% trans "Display calendar view" %}'><span class='fas fa-calendar-alt'></span></button>`,
event: function() {
buttonCallback('calendar');
}
});
}
// List view button
if (!options.disableListView) {
buttons.push({
html: `<button type='button' name='${idx++}' class='btn ${class_list}' title='{% trans "Display list view" %}'><span class='fas fa-th-list'></span></button>`,
event: function() {
buttonCallback('list');
}
});
}
// Tree view button
if (!options.disableTreeView) {
buttons.push({
html: `<button type='button' name='${idx++}' class='btn ${class_tree}' title='{% trans "Display tree view" %}'><span class='fas fa-sitemap'></span></button>`,
event: function() {
buttonCallback('tree');
}
});
}
return buttons;
}
/* Return the 'selected' data rows from a bootstrap table.
* If allowEmpty = false, and the returned dataset is empty,
* then instead try to return *all* the data
*/
function getTableData(table, allowEmpty=false) {
var data = $(table).bootstrapTable('getSelections');
if (data.length == 0 && !allowEmpty) {
data = $(table).bootstrapTable('getData');
}
return data;
}
/**
* Download data from a table, via the API.
* This requires a number of conditions to be met:

View File

@ -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,65 @@ 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")
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 == 'branch' and ('master' in GITHUB_REF or 'master' in GITHUB_BASE_REF):
print("Checking requirements for main 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'
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
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")

View File

@ -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:-./}

View File

@ -1,4 +1,4 @@
#!/bin/sh
#!/bin/bash
# exit when any command fails
set -e

View File

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

View File

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

View File

@ -82,7 +82,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])
@ -94,7 +94,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
@ -554,9 +554,9 @@ def test_translations(c):
# complie regex
reg = re.compile(
r"[a-zA-Z0-9]{1}"+ # match any single letter and number
r"(?![^{\(\<]*[}\)\>])"+ # that is not inside curly brackets, brackets or a tag
r"(?<![^\%][^\(][)][a-z])"+ # that is not a specially formatted variable with singles
r"[a-zA-Z0-9]{1}" + # match any single letter and number # noqa: W504
r"(?![^{\(\<]*[}\)\>])" + # that is not inside curly brackets, brackets or a tag # noqa: W504
r"(?<![^\%][^\(][)][a-z])" + # that is not a specially formatted variable with singles # noqa: W504
r"(?![^\\][\n])" # that is not a newline
)
last_string = ''