Compare commits

..

11 Commits

Author SHA1 Message Date
github-actions[bot]
1c4362c42a Prevent div-by-zero error (#4967) (#4968)
- Div-by-zero could occur when calculating how many items can be built for a part
- Might result if (somehow) the BomItem has a quantity of zero

(cherry picked from commit d8965c6c2b)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-06-05 13:49:39 +10:00
github-actions[bot]
5b6d999091 Fix for 'available' filter (#4952) (#4954)
- Available filter also requires "in stock"

(cherry picked from commit c0dafe155f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-06-02 17:06:09 +10:00
Miklós Márton
970714a76f Fix completeSalesOrderShipment typo in sales_order.js (#4908) 2023-05-27 17:38:14 +10:00
Oliver Walters
59214cacbf Bump version to 0.11.3 2023-05-27 17:37:43 +10:00
Oliver
4868194a0b Fix for PurchaseOrder template (#4891)
- Fixes bug which removes javascript incorrectly
2023-05-25 09:39:55 +10:00
Oliver
53e442f555 Sales order fix backport (#4879)
* Bump version to 0.11.2

* Fix for sales order tables (#4753)

- Allow line items to be allocated after partial shipment
- Fixes https://github.com/inventree/InvenTree/issues/4734
2023-05-24 00:38:00 +10:00
Oliver
9f6d860554 Bump version to 0.11.2 (#4878) 2023-05-24 00:37:40 +10:00
Oliver
a1908f1bf1 Fix "New Sales Order" button (#4832) 2023-05-17 14:48:31 +10:00
Oliver
c29e58aeaa Bug fix for zero quantity pricing (#4766)
- Fix default value for formatPriceRange method
- Display empty value in table

(cherry picked from commit 07d9c2264e)
2023-05-05 22:40:20 +10:00
Oliver
fdaf6d3e19 Implement pagination for stock history tracking API (#4631)
* Bump version number to 0.11.1

* Implement pagination for stock history tracking API

(cherry picked from commit 75696770c6)
2023-04-19 07:09:08 +10:00
Oliver
78badcd65b Bump version number to 0.11.1 (#4630) 2023-04-18 22:56:35 +10:00
1046 changed files with 116052 additions and 199319 deletions

8
.devcontainer/Dockerfile Executable file → Normal file
View File

@@ -4,8 +4,6 @@
ARG VARIANT="3.10-bullseye"
FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
ARG WORKSPACE="/workspaces/InvenTree"
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
ARG NODE_VERSION="none"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
@@ -18,7 +16,7 @@ RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/
# [Optional] Uncomment this section to install additional OS packages.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \
apt-get -y install --no-install-recommends \
git gcc g++ gettext gnupg2 libffi-dev \
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
@@ -44,6 +42,6 @@ RUN pip install --disable-pip-version-check -U -r base_requirements.txt
# preserve command history between container starts
# Ref: https://code.visualstudio.com/remote/advancedcontainers/persist-bash-history
# Folder will be created in 'postCreateCommand' in devcontainer.json as it's not preserved due to the bind mount
RUN echo "export PROMPT_COMMAND='history -a' && export HISTFILE=${WORKSPACE}/dev/commandhistory/.bash_history" >> "/home/vscode/.bashrc"
RUN echo "export PROMPT_COMMAND='history -a' && export HISTFILE=/workspaces/InvenTree/dev/commandhistory/.bash_history" >> "/home/vscode/.bashrc"
WORKDIR ${WORKSPACE}
WORKDIR /workspaces/InvenTree

View File

@@ -11,8 +11,7 @@
// Use -bullseye variants on local on arm64/Apple Silicon.
"VARIANT": "3.10-bullseye",
// Options
"NODE_VERSION": "lts/*",
"WORKSPACE": "${containerWorkspaceFolder}"
"NODE_VERSION": "lts/*"
}
},
@@ -22,7 +21,7 @@
"vscode": {
// Set *default* container specific settings.json values on container create.
"settings": {
"python.defaultInterpreterPath": "${containerWorkspaceFolder}/dev/venv/bin/python",
"python.defaultInterpreterPath": "/workspaces/InvenTree/dev/venv/bin/python",
"python.linting.enabled": true,
"python.linting.pylintEnabled": false,
"python.linting.flake8Enabled": true,
@@ -41,8 +40,7 @@
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"batisteo.vscode-django",
"eamodio.gitlens"
"batisteo.vscode-django"
]
}
},
@@ -56,7 +54,7 @@
},
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "./.devcontainer/postCreateCommand.sh ${containerWorkspaceFolder}",
"postCreateCommand": "./.devcontainer/postCreateCommand.sh",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
@@ -70,22 +68,21 @@
"INVENTREE_DEBUG": "True",
"INVENTREE_DEBUG_LEVEL": "INFO",
"INVENTREE_DB_ENGINE": "sqlite3",
"INVENTREE_DB_NAME": "${containerWorkspaceFolder}/dev/database.sqlite3",
"INVENTREE_MEDIA_ROOT": "${containerWorkspaceFolder}/dev/media",
"INVENTREE_STATIC_ROOT": "${containerWorkspaceFolder}/dev/static",
"INVENTREE_BACKUP_DIR": "${containerWorkspaceFolder}/dev/backup",
"INVENTREE_CONFIG_FILE": "${containerWorkspaceFolder}/dev/config.yaml",
"INVENTREE_SECRET_KEY_FILE": "${containerWorkspaceFolder}/dev/secret_key.txt",
"INVENTREE_PLUGINS_ENABLED": "True",
"INVENTREE_PLUGIN_DIR": "${containerWorkspaceFolder}/dev/plugins",
"INVENTREE_PLUGIN_FILE": "${containerWorkspaceFolder}/dev/plugins.txt",
"INVENTREE_DB_NAME": "/workspaces/InvenTree/dev/database.sqlite3",
"INVENTREE_MEDIA_ROOT": "/workspaces/InvenTree/dev/media",
"INVENTREE_STATIC_ROOT": "/workspaces/InvenTree/dev/static",
"INVENTREE_BACKUP_DIR": "/workspaces/InvenTree/dev/backup",
"INVENTREE_CONFIG_FILE": "/workspaces/InvenTree/dev/config.yaml",
"INVENTREE_SECRET_KEY_FILE": "/workspaces/InvenTree/dev/secret_key.txt",
"INVENTREE_PLUGIN_DIR": "/workspaces/InvenTree/dev/plugins",
"INVENTREE_PLUGIN_FILE": "/workspaces/InvenTree/dev/plugins.txt",
// Python config
"PIP_USER": "no",
// used to load the venv into the PATH and activate it
// used to load the venv into the PATH and avtivate it
// Ref: https://stackoverflow.com/a/56286534
"VIRTUAL_ENV": "${containerWorkspaceFolder}/dev/venv",
"PATH": "${containerWorkspaceFolder}/dev/venv/bin:${containerEnv:PATH}"
"VIRTUAL_ENV": "/workspaces/InvenTree/dev/venv",
"PATH": "/workspaces/InvenTree/dev/venv/bin:${containerEnv:PATH}"
}
}

View File

@@ -1,12 +1,8 @@
#!/bin/bash
# Avoiding Dubious Ownership in Dev Containers for setup commands that use git
# Note that the local workspace directory is passed through as the first argument $1
git config --global --add safe.directory $1
# create folders
mkdir -p $1/dev/{commandhistory,plugins}
cd $1
mkdir -p /workspaces/InvenTree/dev/{commandhistory,plugins}
cd /workspaces/InvenTree
# create venv
python3 -m venv dev/venv
@@ -14,10 +10,5 @@ python3 -m venv dev/venv
# setup InvenTree server
pip install invoke
invoke update
invoke setup-dev
# remove existing gitconfig created by "Avoiding Dubious Ownership" step
# so that it gets copied from host to the container to have your global
# git config in container
rm -f /home/vscode/.gitconfig
inv update
inv setup-dev

View File

@@ -1,3 +0,0 @@
{
"ignore": "D018,H006,H008,H020,H021,H023,H025,H030,H031,T002"
}

View File

@@ -4,7 +4,7 @@ env:
es2021: true
jquery: true
extends:
- eslint:recommended
- google
parserOptions:
ecmaVersion: 12
rules:
@@ -19,8 +19,6 @@ rules:
valid-jsdoc: off
no-multiple-empty-lines: off
comma-dangle: off
no-unused-vars: off
no-useless-escape: off
prefer-spread: off
indent:
- error

4
.github/FUNDING.yml vendored
View File

@@ -1,4 +1,2 @@
github: inventree
ko_fi: inventree
patreon: inventree
custom: [paypal.me/inventree]
ko_fi: inventree

View File

@@ -52,13 +52,6 @@ body:
label: "Version Information"
description: "The version info block."
placeholder: "You can get this by going to the `About InvenTree` section in the upper right corner and clicking on the `copy version information` button"
- type: checkboxes
id: can-reproduce
attributes:
label: "Please verify if you can reproduce this bug on the demo site."
description: "You can sign in at [InvenTree Demo](https://demo.inventree.org) with admin:inventree. Note that this instance runs on the latest dev version, so your bug may be fixed there."
options:
- label: "I can reproduce this bug on the demo site."
- type: textarea
id: logs
attributes:

View File

@@ -1,15 +0,0 @@
name: "Documentation"
description: "Create an issue to improve the documentation"
labels: ["documentation", "triage:not-checked"]
body:
- type: markdown
attributes:
value: |
Create a new issue regarding the InvenTree documentation
- type: textarea
id: repro
attributes:
label: Body of the issue
description: Please provide one distinct thing to fix or a clearly defined enhancement
validations:
required: true

View File

@@ -1,5 +1,5 @@
name: 'Migration test'
description: 'Run migration test sequence'
description: 'Run migration test sequenze'
author: 'InvenTree'
runs:

View File

@@ -1,5 +1,5 @@
name: 'Setup Enviroment'
description: 'Setup the environment for general InvenTree tests'
description: 'Setup the enviroment for general InvenTree tests'
author: 'InvenTree'
inputs:
python:
@@ -48,7 +48,7 @@ runs:
if: ${{ inputs.python == 'true' }}
shell: bash
run: |
python3 -m pip install -U pip
python3 -m pip install pip==23.0.1
pip3 install invoke wheel
- name: Install Specific Python Dependencies
if: ${{ inputs.pip-dependency }}
@@ -62,7 +62,7 @@ runs:
with:
node-version: ${{ env.node_version }}
cache: 'npm'
- name: Install npm packages
- name: Intall npm packages
if: ${{ inputs.npm == 'true' }}
shell: bash
run: npm install

5
.github/release.yml vendored
View File

@@ -4,7 +4,6 @@ changelog:
exclude:
labels:
- translation
- documentation
categories:
- title: Breaking Changes
labels:
@@ -16,11 +15,7 @@ changelog:
- title: New Features
labels:
- Semver-Minor
- feature
- enhancement
- title: Experimental Features
labels:
- experimental
- title: Bug Fixes
labels:
- Semver-Patch

View File

@@ -1,37 +0,0 @@
# Backport tagged issues to a stable branch.
#
# To enable backporting for a pullrequest, add the label "backport" to the PR.
# Additionally, add a label with the prefix "backport-to-" and the target branch
name: Backport
on:
pull_request_target:
types: ["labeled", "closed"]
jobs:
backport:
name: Backport PR
runs-on: ubuntu-latest
if: |
github.event.pull_request.merged == true
&& contains(github.event.pull_request.labels.*.name, 'backport')
&& (
(github.event.action == 'labeled' && github.event.label.name == 'backport')
|| (github.event.action == 'closed')
)
steps:
- name: Backport Action
uses: sqren/backport-github-action@v8.9.3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
auto_backport_label_prefix: backport-to-
add_original_reviewers: true
- name: Info log
if: ${{ success() }}
run: cat ~/.backport/backport.info.log
- name: Debug log
if: ${{ failure() }}
run: cat ~/.backport/backport.debug.log

View File

@@ -20,38 +20,19 @@ on:
push:
branches:
- 'master'
pull_request:
branches:
- 'master'
# pull_request:
# branches:
# - 'master'
jobs:
paths-filter:
name: Filter
runs-on: ubuntu-latest
outputs:
docker: ${{ steps.filter.outputs.docker }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1
id: filter
with:
filters: |
docker:
- .github/workflows/docker.yaml
- docker/**
- docker-compose.yml
- docker.dev.env
- Dockerfile
# Build the docker image
build:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}
cancel-in-progress: true
runs-on: ubuntu-latest
needs: paths-filter
if: needs.paths-filter.outputs.docker == 'true' || github.event_name == 'release' || github.event_name == 'push'
permissions:
contents: read
packages: write
@@ -61,9 +42,9 @@ jobs:
python_version: 3.9
steps:
- name: Check out repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Set Up Python ${{ env.python_version }}
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
with:
python-version: ${{ env.python_version }}
- name: Version Check
@@ -82,6 +63,7 @@ jobs:
docker-compose run inventree-dev-server invoke update
docker-compose run inventree-dev-server invoke setup-dev
docker-compose up -d
docker-compose run inventree-dev-server pip install --upgrade setuptools
docker-compose run inventree-dev-server invoke wait
- name: Check Data Directory
# The following file structure should have been created by the docker image
@@ -99,27 +81,26 @@ jobs:
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> docker.dev.env
docker-compose run inventree-dev-server invoke test --disable-pty
docker-compose run inventree-dev-server invoke test --migrations --disable-pty
docker-compose down
- name: Set up QEMU
if: github.event_name != 'pull_request'
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # pin@v3.0.0
uses: docker/setup-qemu-action@e81a89b1732b9c48d79cd809d8d81d79c4647a18 # pin@v2.1.0
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # pin@v3.0.0
uses: docker/setup-buildx-action@95cb08cb2672c73d4ffd2f422e6d11953d2a9c70 # pin@v2.1.0
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 # pin@v3.1.2
uses: sigstore/cosign-installer@7cc35d7fdbe70d4278a0c96779081e6fac665f88 # pin@v2.8.0
- name: Login to Dockerhub
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # pin@v3.0.0
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a # pin@v2.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into registry ghcr.io
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # pin@v3.0.0
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a # pin@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -128,7 +109,7 @@ jobs:
- name: Extract Docker metadata
if: github.event_name != 'pull_request'
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # pin@v5.0.0
uses: docker/metadata-action@12cce9efe0d49980455aaaca9b071c0befcdd702 # pin@v4.1.0
with:
images: |
inventree/inventree
@@ -137,13 +118,11 @@ jobs:
- name: Build and Push
id: build-and-push
if: github.event_name != 'pull_request'
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # pin@v5.0.0
uses: docker/build-push-action@c56af957549030174b10d6867f20e78cfd7debc5 # pin@v3.2.0
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
sbom: true
provenance: false
target: production
tags: ${{ env.docker_tags }}
build-args: |
@@ -154,4 +133,5 @@ jobs:
if: ${{ false }} # github.event_name != 'pull_request'
env:
COSIGN_EXPERIMENTAL: "true"
run: cosign sign ${{ steps.meta.outputs.tags }}@${{ steps.build-and-push.outputs.digest }}
run: cosign sign ${{ steps.meta.outputs.tags }}@${{
steps.build-and-push.outputs.digest }}

View File

@@ -1,12 +1,15 @@
# Checks for each PR / push
name: QC
name: QC checks
on:
push:
branches-ignore: ['l10*']
branches-ignore:
- l10*
pull_request:
branches-ignore: ['l10*']
branches-ignore:
- l10*
env:
python_version: 3.9
@@ -22,38 +25,13 @@ env:
INVENTREE_BACKUP_DIR: ../test_inventree_backup
jobs:
paths-filter:
name: Filter
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
migrations: ${{ steps.filter.outputs.migrations }}
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
server:
- 'InvenTree/**'
- 'requirements.txt'
- 'requirements-dev.txt'
migrations:
- '**/migrations/**'
- '.github/workflows**'
pep_style:
name: Style [Python]
runs-on: ubuntu-20.04
needs: paths-filter
if: needs.paths-filter.outputs.server == 'true'
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Environment Setup
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
dev-install: true
@@ -68,7 +46,7 @@ jobs:
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Environment Setup
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
npm: true
@@ -82,6 +60,22 @@ jobs:
python InvenTree/manage.py prerender
npx eslint InvenTree/InvenTree/static_i18n/i18n/*.js
html:
name: Style [HTML]
runs-on: ubuntu-20.04
needs: pep_style
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
npm: true
install: true
- name: Check HTML Files
run: npx markuplint **/templates/*.html
pre-commit:
name: Style [pre-commit]
runs-on: ubuntu-20.04
@@ -102,28 +96,6 @@ jobs:
pip install requests
python3 ci/version_check.py
mkdocs:
name: Style [Documentation]
runs-on: ubuntu-20.04
needs: paths-filter
steps:
- name: Checkout Code
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
with:
python-version: ${{ env.python_version }}
- name: Check Config
run: |
pip install pyyaml
python docs/ci/check_mkdocs_config.py
- name: Check Links
run: |
pip install linkcheckmd requests
python -m linkcheckmd docs --recurse
python:
name: Tests - inventree-python
runs-on: ubuntu-20.04
@@ -143,7 +115,7 @@ jobs:
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Environment Setup
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils
@@ -173,7 +145,7 @@ jobs:
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Environment Setup
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
install: true
@@ -184,40 +156,37 @@ jobs:
name: Tests - DB [SQLite] + Coverage
runs-on: ubuntu-20.04
needs: [ 'javascript', 'pre-commit' ]
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
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_PLUGINS_ENABLED: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Environment Setup
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
- name: Coverage Tests
run: invoke coverage
- name: Data Export Test
uses: ./.github/actions/migration
- name: Test Translations
run: invoke translate
- name: Check Migration Files
run: python3 ci/check_migration_files.py
- name: Coverage Tests
run: invoke test --coverage
- name: Upload Coverage Report
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run: coveralls
postgres:
name: Tests - DB [PostgreSQL]
runs-on: ubuntu-20.04
needs: [ 'javascript', 'pre-commit' ]
needs: [ 'javascript', 'html', 'pre-commit' ]
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
@@ -245,7 +214,7 @@ jobs:
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Environment Setup
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
@@ -261,7 +230,7 @@ jobs:
name: Tests - DB [MySQL]
runs-on: ubuntu-20.04
needs: [ 'javascript', 'pre-commit' ]
needs: [ 'javascript', 'html', 'pre-commit' ]
if: github.event_name == 'push'
env:
@@ -290,7 +259,7 @@ jobs:
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Environment Setup
- name: Enviroment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libmysqlclient-dev
@@ -301,82 +270,3 @@ jobs:
run: invoke test
- name: Data Export Test
uses: ./.github/actions/migration
migration-tests:
name: Run Migration Unit Tests
runs-on: ubuntu-latest
needs: paths-filter
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: info
INVENTREE_PLUGINS_ENABLED: false
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg2
dev-install: true
update: true
- name: Run Tests
run: invoke test --migrations --report
migrations-checks:
name: Run Database Migrations
runs-on: ubuntu-latest
needs: paths-filter
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
env:
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
INVENTREE_DEBUG: info
INVENTREE_PLUGINS_ENABLED: false
steps:
- uses: actions/checkout@v3
name: Checkout Code
- name: Environment Setup
uses: ./.github/actions/setup
with:
install: true
- name: Fetch Database
run: git clone --depth 1 https://github.com/inventree/test-db ./test-db
- name: Latest Database
run: |
cp test-db/latest.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.10.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.10.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.11.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.11.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate

35
.github/workflows/social.yml.disabled vendored Normal file
View File

@@ -0,0 +1,35 @@
# Runs on releases
name: Publish release notes
on:
release:
types: [ published ]
jobs:
tweet:
runs-on: ubuntu-latest
steps:
- uses: Eomm/why-don-t-you-tweet@5936bb1fd0096b1c2bbbb7518746638261bb4dae # pin@v1.0.1
with:
tweet-message: "InvenTree release ${{ github.event.release.tag_name }} is out
now! Release notes: ${{ github.event.release.html_url }} #opensource
#inventree"
env:
TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }}
TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }}
TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
reddit:
runs-on: ubuntu-latest
steps:
- uses: bluwy/release-for-reddit-action@4b2d034b5c86a24db24363f1064149a8c2db69b4 # pin@v1.2.0
with:
username: ${{ secrets.REDDIT_USERNAME }}
password: ${{ secrets.REDDIT_PASSWORD }}
app-id: ${{ secrets.REDDIT_APP_ID }}
app-secret: ${{ secrets.REDDIT_APP_SECRET }}
subreddit: InvenTree
title: "InvenTree version ${{ github.event.release.tag_name }} released"
comment: "${{ github.event.release.body }}"

5
.gitignore vendored
View File

@@ -45,6 +45,9 @@ inventree/label.png
inventree/my_special*
_tests*.txt
# Sphinx files
docs/_build
# Local static and media file storage (only when running in development mode)
inventree_media
inventree_static
@@ -97,7 +100,7 @@ node_modules/
maintenance_mode_state.txt
# plugin dev directory
InvenTree/plugins/
plugins/
# Compiled translation files
*.mo

44
.gitpod.yml Normal file
View File

@@ -0,0 +1,44 @@
tasks:
- name: Setup django
before: |
export INVENTREE_DB_ENGINE='sqlite3'
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
export PIP_USER='no'
sudo apt install -y gettext
python3 -m venv venv
source venv/bin/activate
pip install invoke pyyaml
mkdir dev
invoke update
invoke setup-test --ignore-update --path inventree-data
gp sync-done start_server
- name: Start server
init: gp sync-await start_server
command: |
gp sync-await start_server
export INVENTREE_DB_ENGINE='sqlite3'
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
source venv/bin/activate
inv server
ports:
- port: 8000
onOpen: open-preview
github:
prebuilds:
master: true
pullRequests: false
pullRequestsFromForks: true
addBadge: true
addLabel: gitpod-ready
addCheck: false

View File

@@ -15,8 +15,6 @@ env:
- INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
- INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
after_install: contrib/packager.io/postinstall.sh
before:
- contrib/packager.io/before.sh
dependencies:
- curl
- python3

View File

@@ -12,45 +12,29 @@ repos:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: mixed-line-ending
- repo: https://github.com/pycqa/flake8
rev: '6.0.0'
hooks:
- id: flake8
additional_dependencies: [
'flake8-bugbear',
'flake8-comprehensions',
'flake8-docstrings',
'flake8-string-format',
'flake8-tidy-imports',
'pep8-naming'
'pep8-naming ',
]
- repo: https://github.com/pycqa/isort
rev: '5.12.0'
hooks:
- id: isort
- repo: https://github.com/jazzband/pip-tools
rev: 6.13.0
rev: 6.12.3
hooks:
- id: pip-compile
name: pip-compile requirements-dev.in
args: [requirements-dev.in, -o, requirements-dev.txt]
args: [--generate-hashes, requirements-dev.in, -o, requirements-dev.txt]
files: ^requirements-dev\.(in|txt)$
- id: pip-compile
name: pip-compile requirements.txt
args: [requirements.in, -o, requirements.txt]
files: ^requirements\.(in|txt)$
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.30.2
hooks:
- id: djlint-django
- repo: https://github.com/codespell-project/codespell
rev: v2.2.4
hooks:
- id: codespell
exclude: >
(?x)^(
docs/docs/stylesheets/.*|
docs/docs/javascripts/.*|
docs/docs/webfonts/.*
)$

12
.vscode/tasks.json vendored
View File

@@ -1,64 +1,52 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
// the problemMatchers should prevent vscode from asking how it should check the output
"version": "2.0.0",
"tasks": [
{
"label": "clean-settings",
"type": "shell",
"command": "inv clean-settings",
"problemMatcher": [],
},
{
"label": "delete-data",
"type": "shell",
"command": "inv delete-data",
"problemMatcher": [],
},
{
"label": "migrate",
"type": "shell",
"command": "inv migrate",
"problemMatcher": [],
},
{
"label": "server",
"type": "shell",
"command": "inv server",
"problemMatcher": [],
},
{
"label": "setup-dev",
"type": "shell",
"command": "inv setup-dev",
"problemMatcher": [],
},
{
"label": "setup-test",
"type": "shell",
"command": "inv setup-test --path dev/inventree-demo-dataset",
"problemMatcher": [],
},
{
"label": "superuser",
"type": "shell",
"command": "inv superuser",
"problemMatcher": [],
},
{
"label": "test",
"type": "shell",
"command": "inv test",
"problemMatcher": [],
},
{
"label": "update",
"type": "shell",
"command": "inv update",
"problemMatcher": [],
},
]
}

View File

@@ -1,4 +1,4 @@
Hi there, thank you for your interest in contributing!
Hi there, thank you for your intrest in contributing!
Please read the contribution guidelines below, before submitting your first pull request to the InvenTree codebase.
## Quickstart
@@ -19,7 +19,7 @@ pip install invoke && invoke setup-dev --tests
```bash
git clone https://github.com/inventree/InvenTree.git && cd InvenTree
docker compose run inventree-dev-server invoke install
docker compose run inventree-dev-server invoke setup-test --dev
docker compose run inventree-dev-server invoke setup-test
docker compose up -d
```
@@ -33,7 +33,7 @@ Run the following command to set up all toolsets for development.
invoke setup-dev
```
*We recommend you run this command before starting to contribute. This will install and set up `pre-commit` to run some checks before each commit and help reduce errors.*
*We recommend you run this command 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
@@ -50,7 +50,7 @@ The HEAD of the "main" or "master" branch of InvenTree represents the current "l
- All feature branches are merged into master
- All bug fixes are merged into master
**No pushing to master:** New features must be submitted as a pull request from a separate branch (one branch per feature).
**No pushing to master:** New featues must be submitted as a pull request from a separate branch (one branch per feature).
### Feature Branches
@@ -70,7 +70,7 @@ The HEAD of the "stable" branch represents the latest stable release code.
#### Release Candidate Branches
- Release candidate branches are made from master, and merged into stable.
- RC branches are targeted at a major/minor version e.g. "0.5"
- RC branches are targetted at a major/minor version e.g. "0.5"
- When a release candidate branch is merged into *stable*, the release is tagged
#### Bugfix Branches
@@ -101,7 +101,7 @@ django-upgrade --target-version 3.2 `find . -name "*.py"`
```
## Credits
If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree/blob/master/docs/docs/credits.md). Please try to do that as timely as possible.
If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree-docs/blob/master/docs/credits.md). Please try to do that as timely as possible.
## Migration Files
@@ -123,44 +123,14 @@ The InvenTree code base makes use of [GitHub actions](https://github.com/feature
The various github actions can be found in the `./github/workflows` directory
### Run tests locally
To run test locally, use:
```
invoke test
```
To run only partial tests, for example for a module use:
```
invoke test --runtest order
```
## Code Style
Submitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
Please write docstrings for each function and class - we follow the [google doc-style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for python. Docstrings for general javascript code is encouraged! Docstyles are checked by `invoke style`.
### Django templates
Django are checked by [djlint](https://github.com/Riverside-Healthcare/djlint) through pre-commit.
The following rules out of the [default set](https://djlint.com/docs/linter/) are not applied:
```bash
D018: (Django) Internal links should use the { % url ... % } pattern
H006: Img tag should have height and width attributes
H008: Attributes should be double quoted
H021: Inline styles should be avoided
H023: Do not use entity references
H025: Tag seems to be an orphan
H030: Consider adding a meta description
H031: Consider adding meta keywords
T002: Double quotes should be used in tags
```
## Documentation
New features or updates to existing features should be accompanied by user documentation.
New features or updates to existing features should be accompanied by user documentation. A PR with associated documentation should link to the matching PR at https://github.com/inventree/inventree-docs/
## Translations
@@ -187,16 +157,16 @@ user_facing_string = _('This string will be exposed to the translation engine!')
HTML and javascript files are passed through the django templating engine. Translatable strings are implemented as follows:
```html
{ % load i18n % }
{% load i18n %}
<span>{ % trans "This string will be translated" % } - this string will not!</span>
<span>{% trans "This string will be translated" %} - this string will not!</span>
```
## Github use
### Tags
The tags describe issues and PRs in multiple areas:
| Area | Name | Description |
| --- | --- | --- |
|---|---|---|
| Triage Labels | | |
| | triage:not-checked | Item was not checked by the core team |
| | triage:not-approved | Item is not green-light by maintainer |
@@ -205,13 +175,10 @@ The tags describe issues and PRs in multiple areas:
| | bug | Identifies a bug which needs to be addressed |
| | dependency | Relates to a project dependency |
| | duplicate | Duplicate of another issue or PR |
| | enhancement | This is an suggested enhancement, extending the functionality of an existing feature |
| | experimental | This is a new *experimental* feature which needs to be enabled manually |
| | feature | This is a new feature, introducing novel functionality |
| | enhancement | This is an suggested enhancement or new feature |
| | help wanted | Assistance required |
| | invalid | This issue or PR is considered invalid |
| | inactive | Indicates lack of activity |
| | migration | Database migration, requires special attention |
| | question | This is a question |
| | roadmap | This is a roadmap feature with no immediate plans for implementation |
| | security | Relates to a security issue |
@@ -230,9 +197,7 @@ The tags describe issues and PRs in multiple areas:
| | stock | Stock item management |
| | user interface | User interface |
| Ecosystem Labels | | |
| | backport | Tags that the issue will be backported to a stable branch as a bug-fix |
| | demo | Relates to the InvenTree demo server or dataset |
| | docker | Docker / docker-compose |
| | CI | CI / unit testing ecosystem |
| | refactor | Refactoring existing code |
| | setup | Relates to the InvenTree setup / installation process |

View File

@@ -1,10 +1,5 @@
"""Admin classes"""
from django.contrib import admin
from django.http.request import HttpRequest
from djmoney.contrib.exchange.admin import RateAdmin
from djmoney.contrib.exchange.models import Rate
from import_export.resources import ModelResource
@@ -36,27 +31,3 @@ class InvenTreeResource(ModelResource):
row[idx] = val
return row
def get_fields(self, **kwargs):
"""Return fields, with some common exclusions"""
fields = super().get_fields(**kwargs)
fields_to_exclude = [
'metadata',
'lft', 'rght', 'tree_id', 'level',
]
return [f for f in fields if f.column_name not in fields_to_exclude]
class CustomRateAdmin(RateAdmin):
"""Admin interface for the Rate class"""
def has_add_permission(self, request: HttpRequest) -> bool:
"""Disable the 'add' permission for Rate objects"""
return False
admin.site.unregister(Rate)
admin.site.register(Rate, CustomRateAdmin)

View File

@@ -59,39 +59,14 @@ class NotFoundView(AjaxView):
permission_classes = [permissions.AllowAny]
def not_found(self, request):
"""Return a 404 error"""
return JsonResponse(
{
'detail': _('API endpoint not found'),
'url': request.build_absolute_uri(),
},
status=404
)
def options(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def get(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
"""Proces an `not found` event on the API."""
data = {
'details': _('API endpoint not found'),
'url': request.build_absolute_uri(),
}
def post(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def patch(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def put(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def delete(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
return JsonResponse(data, status=404)
class BulkDeleteMixin:
@@ -331,6 +306,47 @@ class APISearchView(APIView):
return Response(results)
class StatusView(APIView):
"""Generic API endpoint for discovering information on 'status codes' for a particular model.
This class should be implemented as a subclass for each type of status.
For example, the API endpoint /stock/status/ will have information about
all available 'StockStatus' codes
"""
permission_classes = [
permissions.IsAuthenticated,
]
# Override status_class for implementing subclass
MODEL_REF = 'statusmodel'
def get_status_model(self, *args, **kwargs):
"""Return the StatusCode moedl based on extra parameters passed to the view"""
status_model = self.kwargs.get(self.MODEL_REF, None)
if status_model is None:
raise ValidationError(f"StatusView view called without '{self.MODEL_REF}' parameter")
return status_model
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes"""
status_class = self.get_status_model()
if not status_class:
raise NotImplementedError("status_class not defined for this endpoint")
data = {
'class': status_class.__name__,
'values': status_class.dict(),
}
return Response(data)
class MetadataView(RetrieveUpdateAPI):
"""Generic API endpoint for reading and editing metadata for a model"""

View File

@@ -1,18 +1,12 @@
"""Helper functions for unit testing / CI"""
"""Helper functions for performing API unit tests."""
import csv
import io
import json
import re
from contextlib import contextmanager
from pathlib import Path
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.db import connections
from django.contrib.auth.models import Group
from django.http.response import StreamingHttpResponse
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from rest_framework.test import APITestCase
@@ -21,75 +15,6 @@ from plugin import registry
from plugin.models import PluginConfig
def addUserPermission(user, permission):
"""Shortcut function for adding a certain permission to a user."""
perm = Permission.objects.get(codename=permission)
user.user_permissions.add(perm)
def addUserPermissions(user, permissions):
"""Shortcut function for adding multiple permissions to a user."""
for permission in permissions:
addUserPermission(user, permission)
def getMigrationFileNames(app):
"""Return a list of all migration filenames for provided app."""
local_dir = Path(__file__).parent
files = local_dir.joinpath('..', app, 'migrations').iterdir()
# Regex pattern for migration files
regex = re.compile(r"^[\d]+_.*\.py$")
migration_files = []
for f in files:
if regex.match(f.name):
migration_files.append(f.name)
return migration_files
def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
"""Return the filename associated with the oldest migration."""
oldest_num = -1
oldest_file = None
for f in getMigrationFileNames(app):
if ignore_initial and f.startswith('0001_initial'):
continue
num = int(f.split('_')[0])
if oldest_file is None or num < oldest_num:
oldest_num = num
oldest_file = f
if exclude_extension:
oldest_file = oldest_file.replace('.py', '')
return oldest_file
def getNewestMigrationFile(app, exclude_extension=True):
"""Return the filename associated with the newest migration."""
newest_file = None
newest_num = -1
for f in getMigrationFileNames(app):
num = int(f.split('_')[0])
if newest_file is None or num > newest_num:
newest_num = num
newest_file = f
if exclude_extension:
newest_file = newest_file.replace('.py', '')
return newest_file
class UserMixin:
"""Mixin to setup a user and login for tests.
@@ -237,38 +162,9 @@ class ExchangeRateMixin:
Rate.objects.bulk_create(items)
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
"""Testcase with user setup buildin."""
pass
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Base class for running InvenTree API tests."""
@contextmanager
def assertNumQueriesLessThan(self, value, using='default', verbose=False, debug=False):
"""Context manager to check that the number of queries is less than a certain value.
Example:
with self.assertNumQueriesLessThan(10):
# Do some stuff
Ref: https://stackoverflow.com/questions/1254170/django-is-there-a-way-to-count-sql-queries-from-an-unit-test/59089020#59089020
"""
with CaptureQueriesContext(connections[using]) as context:
yield # your test will be run here
if verbose:
msg = "\r\n%s" % json.dumps(context.captured_queries, indent=4)
else:
msg = None
n = len(context.captured_queries)
if debug:
print(f"Expected less than {value} queries, got {n} queries")
self.assertLess(n, value, msg=msg)
def checkResponse(self, url, method, expected_code, response):
"""Debug output for an unexpected response"""
@@ -369,7 +265,8 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Download a file from the server, and return an in-memory file."""
response = self.client.get(url, data=data, format='json')
self.checkResponse(url, 'DOWNLOAD_FILE', expected_code, response)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
# Check that the response is of the correct type
if not isinstance(response, StreamingHttpResponse):
@@ -387,27 +284,27 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
if decode:
# Decode data and return as StringIO file object
file = io.StringIO()
file.name = file
file.write(response.getvalue().decode('UTF-8'))
fo = io.StringIO()
fo.name = fo
fo.write(response.getvalue().decode('UTF-8'))
else:
# Return a a BytesIO file object
file = io.BytesIO()
file.name = fn
file.write(response.getvalue())
fo = io.BytesIO()
fo.name = fn
fo.write(response.getvalue())
file.seek(0)
fo.seek(0)
return file
return fo
def process_csv(self, file_object, delimiter=',', required_cols=None, excluded_cols=None, required_rows=None):
def process_csv(self, fo, delimiter=',', required_cols=None, excluded_cols=None, required_rows=None):
"""Helper function to process and validate a downloaded csv file."""
# Check that the correct object type has been passed
self.assertTrue(isinstance(file_object, io.StringIO))
self.assertTrue(isinstance(fo, io.StringIO))
file_object.seek(0)
fo.seek(0)
reader = csv.reader(file_object, delimiter=delimiter)
reader = csv.reader(fo, delimiter=delimiter)
headers = []
rows = []

View File

@@ -2,79 +2,11 @@
# InvenTree API version
INVENTREE_API_VERSION = 127
INVENTREE_API_VERSION = 107
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v127 -> 2023-06-24 : https://github.com/inventree/InvenTree/pull/5094
- Enhancements for the PartParameter API endpoints
v126 -> 2023-06-19 : https://github.com/inventree/InvenTree/pull/5075
- Adds API endpoint for setting the "category" for multiple parts simultaneously
v125 -> 2023-06-17 : https://github.com/inventree/InvenTree/pull/5064
- Adds API endpoint for setting the "status" field for multiple stock items simultaneously
v124 -> 2023-06-17 : https://github.com/inventree/InvenTree/pull/5057
- Add "created_before" and "created_after" filters to the Part API
v123 -> 2023-06-15 : https://github.com/inventree/InvenTree/pull/5019
- Add Metadata to: Plugin Config
v122 -> 2023-06-14 : https://github.com/inventree/InvenTree/pull/5034
- Adds new BuildLineLabel label type
v121 -> 2023-06-14 : https://github.com/inventree/InvenTree/pull/4808
- Adds "ProjectCode" link to Build model
v120 -> 2023-06-07 : https://github.com/inventree/InvenTree/pull/4855
- Major overhaul of the build order API
- Adds new BuildLine model
v119 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4898
- Add Metadata to: Part test templates, Part parameters, Part category parameter templates, BOM item substitute, Related Parts, Stock item test result
v118 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4935
- Adds extra fields for the PartParameterTemplate model
v117 -> 2023-05-22 : https://github.com/inventree/InvenTree/pull/4854
- Part.units model now supports physical units (e.g. "kg", "m", "mm", etc)
- Replaces SupplierPart "pack_size" field with "pack_quantity"
- New field supports physical units, and allows for conversion between compatible units
v116 -> 2023-05-18 : https://github.com/inventree/InvenTree/pull/4823
- Updates to part parameter implementation, to use physical units
v115 - > 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846
- Adds ability to partially scrap a build output
v114 -> 2023-05-16 : https://github.com/inventree/InvenTree/pull/4825
- Adds "delivery_date" to shipments
>>>>>>> inventree/master
v113 -> 2023-05-13 : https://github.com/inventree/InvenTree/pull/4800
- Adds API endpoints for scrapping a build output
v112 -> 2023-05-13: https://github.com/inventree/InvenTree/pull/4741
- Adds flag use_pack_size to the stock addition API, which allows addings packs
v111 -> 2023-05-02 : https://github.com/inventree/InvenTree/pull/4367
- Adds tags to the Part serializer
- Adds tags to the SupplierPart serializer
- Adds tags to the ManufacturerPart serializer
- Adds tags to the StockItem serializer
- Adds tags to the StockLocation serializer
v110 -> 2023-04-26 : https://github.com/inventree/InvenTree/pull/4698
- Adds 'order_currency' field for PurchaseOrder / SalesOrder endpoints
v109 -> 2023-04-19 : https://github.com/inventree/InvenTree/pull/4636
- Adds API endpoints for the "ProjectCode" model
v108 -> 2023-04-17 : https://github.com/inventree/InvenTree/pull/4615
- Adds functionality to upload images for rendering in markdown notes
v107 -> 2023-04-04 : https://github.com/inventree/InvenTree/pull/4575
- Adds barcode support for PurchaseOrder model
- Adds barcode support for ReturnOrder model
@@ -235,7 +167,7 @@ v64 -> 2022-07-08 : https://github.com/inventree/InvenTree/pull/3310
- Allow BOM List API endpoint to be filtered by "on_order" parameter
v63 -> 2022-07-06 : https://github.com/inventree/InvenTree/pull/3301
- Allow BOM List API endpoint to be filtered by "available_stock" parameter
- Allow BOM List API endpoint to be filtered by "available_stock" paramater
v62 -> 2022-07-05 : https://github.com/inventree/InvenTree/pull/3296
- Allows search on BOM List API endpoint

View File

@@ -11,7 +11,6 @@ from django.core.exceptions import AppRegistryNotReady
from django.db import transaction
from django.db.utils import IntegrityError
import InvenTree.conversion
import InvenTree.tasks
from InvenTree.config import get_setting
from InvenTree.ready import canAppAccessDatabase, isInTestMode
@@ -30,8 +29,8 @@ class InvenTreeConfig(AppConfig):
- Checking if migrations should be run
- Cleaning up tasks
- Starting regular tasks
- Updating exchange rates
- Collecting notification methods
- Updateing exchange rates
- Collecting notification mehods
- Adding users set in the current environment
"""
if canAppAccessDatabase() or settings.TESTING_ENV:
@@ -47,9 +46,6 @@ class InvenTreeConfig(AppConfig):
self.collect_notification_methods()
# Ensure the unit registry is loaded
InvenTree.conversion.get_unit_registry()
if canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup()
@@ -84,7 +80,7 @@ class InvenTreeConfig(AppConfig):
minutes=task.minutes,
)
# Put at least one task onto the background worker stack,
# Put at least one task onto the backround worker stack,
# which will be processed as soon as the worker comes online
InvenTree.tasks.offload_task(
InvenTree.tasks.heartbeat,
@@ -126,22 +122,19 @@ class InvenTreeConfig(AppConfig):
update = False
try:
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
if backend.exists():
backend = backend.first()
last_update = backend.last_update
last_update = backend.last_update
if last_update is None:
# Never been updated
logger.info("Exchange backend has never been updated")
update = True
if last_update is None:
# Never been updated
logger.info("Exchange backend has never been updated")
update = True
# Backend currency has changed?
if base_currency != backend.base_currency:
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
update = True
# Backend currency has changed?
if base_currency != backend.base_currency:
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
update = True
except (ExchangeBackend.DoesNotExist):
logger.info("Exchange backend not found - updating")
@@ -155,7 +148,7 @@ class InvenTreeConfig(AppConfig):
try:
update_exchange_rates()
except Exception as e:
logger.error(f"Error updating exchange rates: {e} ({type(e)})")
logger.error(f"Error updating exchange rates: {e}")
def add_user_on_startup(self):
"""Add a user on startup."""
@@ -195,8 +188,8 @@ class InvenTreeConfig(AppConfig):
else:
new_user = user.objects.create_superuser(add_user, add_email, add_password)
logger.info(f'User {str(new_user)} was created!')
except IntegrityError:
logger.warning(f'The user "{add_user}" could not be created')
except IntegrityError as _e:
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
# do not try again
settings.USER_ADDED = True

View File

@@ -1,12 +1,12 @@
"""Pull rendered copies of the templated.
Only used for testing the js files! - This file is omitted from coverage.
Only used for testing the js files! - This file is omited from coverage.
"""
import os # pragma: no cover
import pathlib # pragma: no cover
from InvenTree.unit_test import InvenTreeTestCase # pragma: no cover
from InvenTree.helpers import InvenTreeTestCase # pragma: no cover
class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover
@@ -17,7 +17,7 @@ class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover
"""
def download_file(self, filename, prefix):
"""Function to `download`(copy) a file to a temporary firectory."""
"""Function to `download`(copy) a file to a temporay firectory."""
url = os.path.join(prefix, filename)
response = self.client.get(url)

View File

@@ -200,7 +200,7 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
def get_boolean_setting(env_var=None, config_key=None, default_value=False):
"""Helper function for retrieving a boolean configuration setting"""
"""Helper function for retreiving a boolean configuration setting"""
return is_true(get_setting(env_var, config_key, default_value))

View File

@@ -2,10 +2,11 @@
"""Provides extra global data to all templates."""
import InvenTree.email
import InvenTree.status
from generic.states import StatusCode
from InvenTree.helpers import inheritors
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
ReturnOrderLineStatus, ReturnOrderStatus,
SalesOrderStatus, StockHistoryCode,
StockStatus)
from users.models import RuleSet, check_user_role
@@ -27,7 +28,7 @@ def health_status(request):
status = {
'django_q_running': InvenTree.status.is_worker_running(),
'email_configured': InvenTree.email.is_email_configured(),
'email_configured': InvenTree.status.is_email_configured(),
}
# The following keys are required to denote system health
@@ -55,7 +56,17 @@ def status_codes(request):
return {}
request._inventree_status_codes = True
return {cls.__name__: cls.template_context() for cls in inheritors(StatusCode)}
return {
# Expose the StatusCode classes to the templates
'ReturnOrderStatus': ReturnOrderStatus,
'ReturnOrderLineStatus': ReturnOrderLineStatus,
'SalesOrderStatus': SalesOrderStatus,
'PurchaseOrderStatus': PurchaseOrderStatus,
'BuildStatus': BuildStatus,
'StockStatus': StockStatus,
'StockHistoryCode': StockHistoryCode,
}
def user_roles(request):

View File

@@ -1,108 +0,0 @@
"""Helper functions for converting between units."""
import logging
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import pint
_unit_registry = None
logger = logging.getLogger('inventree')
def get_unit_registry():
"""Return a custom instance of the Pint UnitRegistry."""
global _unit_registry
# Cache the unit registry for speedier access
if _unit_registry is None:
reload_unit_registry()
return _unit_registry
def reload_unit_registry():
"""Reload the unit registry from the database.
This function is called at startup, and whenever the database is updated.
"""
import time
t_start = time.time()
global _unit_registry
_unit_registry = pint.UnitRegistry()
# Define some "standard" additional units
_unit_registry.define('piece = 1')
_unit_registry.define('each = 1 = ea')
_unit_registry.define('dozen = 12 = dz')
_unit_registry.define('hundred = 100')
_unit_registry.define('thousand = 1000')
# TODO: Allow for custom units to be defined in the database
dt = time.time() - t_start
logger.debug(f'Loaded unit registry in {dt:.3f}s')
def convert_physical_value(value: str, unit: str = None):
"""Validate that the provided value is a valid physical quantity.
Arguments:
value: Value to validate (str)
unit: Optional unit to convert to, and validate against
Raises:
ValidationError: If the value is invalid or cannot be converted to the specified unit
Returns:
The converted quantity, in the specified units
"""
# Ensure that the value is a string
value = str(value).strip()
# Error on blank values
if not value:
raise ValidationError(_('No value provided'))
ureg = get_unit_registry()
error = ''
try:
# Convert to a quantity
val = ureg.Quantity(value)
if unit:
if val.units == ureg.dimensionless:
# If the provided value is dimensionless, assume that the unit is correct
val = ureg.Quantity(value, unit)
else:
# Convert to the provided unit (may raise an exception)
val = val.to(unit)
# At this point we *should* have a valid pint value
# To double check, look at the maginitude
float(val.magnitude)
except (TypeError, ValueError, AttributeError):
error = _('Provided value is not a valid number')
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
error = _('Provided value has an invalid unit')
except pint.errors.DimensionalityError:
error = _('Provided value could not be converted to the specified unit')
if error:
if unit:
error += f' ({unit})'
raise ValidationError(error)
# Return the converted value
return val

View File

@@ -1,90 +0,0 @@
"""Code for managing email functionality in InvenTree."""
import logging
from django.conf import settings
from django.core import mail as django_mail
import InvenTree.ready
import InvenTree.tasks
logger = logging.getLogger('inventree')
def is_email_configured():
"""Check if email backend is configured.
NOTE: This does not check if the configuration is valid!
"""
configured = True
testing = settings.TESTING
if InvenTree.ready.isInTestMode():
return False
if InvenTree.ready.isImportingData():
return False
if not settings.EMAIL_HOST:
configured = False
# Display warning unless in test mode
if not testing: # pragma: no cover
logger.debug("EMAIL_HOST is not configured")
# Display warning unless in test mode
if not settings.EMAIL_HOST_USER and not testing: # pragma: no cover
logger.debug("EMAIL_HOST_USER is not configured")
# Display warning unless in test mode
if not settings.EMAIL_HOST_PASSWORD and testing: # pragma: no cover
logger.debug("EMAIL_HOST_PASSWORD is not configured")
# Email sender must be configured
if not settings.DEFAULT_FROM_EMAIL:
configured = False
if not testing: # pragma: no cover
logger.warning("DEFAULT_FROM_EMAIL is not configured")
return configured
def send_email(subject, body, recipients, from_email=None, html_message=None):
"""Send an email with the specified subject and body, to the specified recipients list."""
if type(recipients) == str:
recipients = [recipients]
import InvenTree.ready
import InvenTree.status
if InvenTree.ready.isImportingData():
# If we are importing data, don't send emails
return
if not InvenTree.email.is_email_configured() and not settings.TESTING:
# Email is not configured / enabled
return
# If a *from_email* is not specified, ensure that the default is set
if not from_email:
from_email = settings.DEFAULT_FROM_EMAIL
# If we still don't have a valid from_email, then we can't send emails
if not from_email:
if settings.TESTING:
from_email = 'from@test.com'
else:
logger.error("send_email failed: DEFAULT_FROM_EMAIL not specified")
return
InvenTree.tasks.offload_task(
django_mail.send_mail,
subject,
body,
from_email,
recipients,
fail_silently=False,
html_message=html_message
)

View File

@@ -18,8 +18,6 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework.response import Response
import InvenTree.sentry
logger = logging.getLogger('inventree')
@@ -34,7 +32,7 @@ def log_error(path):
kind, info, data = sys.exc_info()
# Check if the error is on the ignore list
# Check if the eror is on the ignore list
if kind in settings.IGNORED_ERRORS:
return
@@ -63,12 +61,18 @@ def exception_handler(exc, context):
"""
response = None
# Pass exception to sentry.io handler
try:
InvenTree.sentry.report_exception(exc)
except Exception:
# If sentry.io fails, we don't want to crash the server!
pass
if settings.SENTRY_ENABLED and settings.SENTRY_DSN and not settings.DEBUG:
# Report this exception to sentry.io
from sentry_sdk import capture_exception
# The following types of errors are ignored, they are "expected"
do_not_report = [
DjangoValidationError,
DRFValidationError,
]
if not any([isinstance(exc, err) for err in do_not_report]):
capture_exception(exc)
# Catch any django validation error, and re-throw a DRF validation error
if isinstance(exc, DjangoValidationError):

View File

@@ -1,11 +1,12 @@
"""Exchangerate backend to use `frankfurter.app` to get rates."""
"""Exchangerate backend to use `exchangerate.host` to get rates."""
from decimal import Decimal
import ssl
from urllib.error import URLError
from urllib.request import urlopen
from django.db.utils import OperationalError
import requests
import certifi
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
from common.settings import currency_code_default, currency_codes
@@ -14,19 +15,19 @@ from common.settings import currency_code_default, currency_codes
class InvenTreeExchange(SimpleExchangeBackend):
"""Backend for automatically updating currency exchange rates.
Uses the `frankfurter.app` service API
Uses the `exchangerate.host` service API
"""
name = "InvenTreeExchange"
def __init__(self):
"""Set API url."""
self.url = "https://api.frankfurter.app/latest"
self.url = "https://api.exchangerate.host/latest"
super().__init__()
def get_params(self):
"""Placeholder to set API key. Currently not required by `frankfurter.app`."""
"""Placeholder to set API key. Currently not required by `exchangerate.host`."""
# No API key is required
return {
}
@@ -39,23 +40,14 @@ class InvenTreeExchange(SimpleExchangeBackend):
url = self.get_url(**kwargs)
try:
response = requests.get(url=url, timeout=5)
return response.content
context = ssl.create_default_context(cafile=certifi.where())
response = urlopen(url, timeout=5, context=context)
return response.read()
except Exception:
# Something has gone wrong, but we can just try again next time
# Raise a TypeError so the outer function can handle this
raise TypeError
def get_rates(self, **params):
"""Intersect the requested currency codes with the available codes."""
rates = super().get_rates(**params)
# Add the base currency to the rates
base_currency = params.get('base', currency_code_default())
rates[base_currency] = Decimal("1.0")
return rates
def update_rates(self, base_currency=None):
"""Set the requested currency codes and get rates."""
# Set default - see B008

View File

@@ -4,7 +4,7 @@ import sys
from decimal import Decimal
from django import forms
from django.db import models
from django.db import models as models
from django.utils.translation import gettext_lazy as _
from djmoney.forms.fields import MoneyField
@@ -19,7 +19,6 @@ from .validators import AllowedURLValidator, allowable_url_schemes
class InvenTreeRestURLField(RestURLField):
"""Custom field for DRF with custom scheme vaildators."""
def __init__(self, **kwargs):
"""Update schemes."""
@@ -112,7 +111,6 @@ class InvenTreeModelMoneyField(ModelMoneyField):
class InvenTreeMoneyField(MoneyField):
"""Custom MoneyField for clean migrations while using dynamic currency settings."""
def __init__(self, *args, **kwargs):
"""Override initial values with the real info from database."""
kwargs.update(money_kwargs())

View File

@@ -3,7 +3,7 @@
from django_filters import rest_framework as rest_filters
from rest_framework import filters
import InvenTree.helpers
from InvenTree.helpers import str2bool
class InvenTreeSearchFilter(filters.SearchFilter):
@@ -13,10 +13,10 @@ class InvenTreeSearchFilter(filters.SearchFilter):
"""Return a set of search fields for the request, adjusted based on request params.
The following query params are available to 'augment' the search (in decreasing order of priority)
- search_regex: If True, search is performed on 'regex' comparison
- search_regex: If True, search is perfomed on 'regex' comparison
"""
regex = InvenTree.helpers.str2bool(request.query_params.get('search_regex', False))
regex = str2bool(request.query_params.get('search_regex', False))
search_fields = super().get_search_fields(view, request)
@@ -37,7 +37,7 @@ class InvenTreeSearchFilter(filters.SearchFilter):
Depending on the request parameters, we may "augment" these somewhat
"""
whole = InvenTree.helpers.str2bool(request.query_params.get('search_whole', False))
whole = str2bool(request.query_params.get('search_whole', False))
terms = []

View File

@@ -91,7 +91,7 @@ def construct_format_regex(fmt_string: str) -> str:
# Add a named capture group for the format entry
if name:
# Check if integer values are required
# Check if integer values are requried
if format.endswith('d'):
chr = '\d'
else:

View File

@@ -12,7 +12,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
from allauth.account.forms import SignupForm, set_form_field_order
from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter
@@ -21,8 +21,6 @@ from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
PrependedText)
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Field, Layout
from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers
from common.models import InvenTreeSetting
from InvenTree.exceptions import log_error
@@ -161,30 +159,11 @@ class SetPasswordForm(HelperForm):
old_password = forms.CharField(
label=_("Old password"),
strip=False,
required=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}),
)
# override allauth
class CustomLoginForm(LoginForm):
"""Custom login form to override default allauth behaviour"""
def login(self, request, redirect_url=None):
"""Perform login action.
First check that:
- A valid user has been supplied
"""
if not self.user:
# No user supplied - redirect to the login page
return HttpResponseRedirect(reverse('account_login'))
# Now perform default login action
return super().login(request, redirect_url)
class CustomSignupForm(SignupForm):
"""Override to use dynamic settings."""
@@ -227,20 +206,15 @@ class CustomSignupForm(SignupForm):
return cleaned_data
def registration_enabled():
"""Determine whether user registration is enabled."""
return settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'))
class RegistratonMixin:
"""Mixin to check if registration should be enabled."""
def is_open_for_signup(self, request, *args, **kwargs):
"""Check if signup is enabled in settings.
Configure the class variable `REGISTRATION_SETTING` to set which setting should be used, default: `LOGIN_ENABLE_REG`.
Configure the class variable `REGISTRATION_SETTING` to set which setting should be used, defualt: `LOGIN_ENABLE_REG`.
"""
if registration_enabled():
if settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG')):
return super().is_open_for_signup(request, *args, **kwargs)
return False
@@ -279,7 +253,7 @@ class RegistratonMixin:
group = Group.objects.get(id=start_group)
user.groups.add(group)
except Group.DoesNotExist:
logger.error('The setting `SIGNUP_GROUP` contains an non existent group', start_group)
logger.error('The setting `SIGNUP_GROUP` contains an non existant group', start_group)
user.save()
return user
@@ -302,7 +276,7 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, Default
try:
result = super().send_mail(template_prefix, email, context)
except Exception:
# An exception occurred while attempting to send email
# An exception ocurred while attempting to send email
# Log it (for admin users) and return silently
log_error('account email')
result = False
@@ -311,15 +285,6 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, Default
return False
def get_email_confirmation_url(self, request, emailconfirmation):
"""Construct the email confirmation url"""
from InvenTree.helpers_model import construct_absolute_url
url = super().get_email_confirmation_url(request, emailconfirmation)
url = construct_absolute_url(url)
return url
class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter):
"""Override of adapter to use dynamic settings."""
@@ -354,20 +319,3 @@ class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocial
# Otherwise defer to the original allauth adapter.
return super().login(request, user)
# override dj-rest-auth
class CustomRegisterSerializer(RegisterSerializer):
"""Override of serializer to use dynamic settings."""
email = serializers.EmailField()
def __init__(self, instance=None, data=..., **kwargs):
"""Check settings to influence which fields are needed."""
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
super().__init__(instance, data, **kwargs)
def save(self, request):
"""Override to check if registration is open."""
if registration_enabled():
return super().save(request)
raise forms.ValidationError(_('Registration is disabled.'))

View File

@@ -8,28 +8,44 @@ import os
import os.path
import re
from decimal import Decimal, InvalidOperation
from pathlib import Path
from wsgiref.util import FileWrapper
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.staticfiles.storage import StaticFilesStorage
from django.core.exceptions import FieldError, ValidationError
from django.core.files.storage import default_storage
from django.core.validators import URLValidator
from django.http import StreamingHttpResponse
from django.test import TestCase
from django.utils.translation import gettext_lazy as _
import moneyed.localization
import regex
import requests
from bleach import clean
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from PIL import Image
import InvenTree.version
from common.models import InvenTreeSetting
from common.notifications import (InvenTreeNotificationBodies,
NotificationBody, trigger_notification)
from common.settings import currency_code_default
from .api_tester import ExchangeRateMixin, UserMixin
from .settings import MEDIA_URL, STATIC_URL
logger = logging.getLogger('inventree')
def getSetting(key, backup_value=None):
"""Shortcut for reading a setting value from the database."""
return InvenTreeSetting.get_setting(key, backup_value=backup_value)
def generateTestKey(test_name):
"""Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template.
@@ -72,6 +88,130 @@ def getStaticUrl(filename):
return os.path.join(STATIC_URL, str(filename))
def construct_absolute_url(*arg):
"""Construct (or attempt to construct) an absolute URL from a relative URL.
This is useful when (for example) sending an email to a user with a link
to something in the InvenTree web framework.
This requires the BASE_URL configuration option to be set!
"""
base = str(InvenTreeSetting.get_setting('INVENTREE_BASE_URL'))
url = '/'.join(arg)
if not base:
return url
# Strip trailing slash from base url
if base.endswith('/'):
base = base[:-1]
if url.startswith('/'):
url = url[1:]
url = f"{base}/{url}"
return url
def download_image_from_url(remote_url, timeout=2.5):
"""Download an image file from a remote URL.
This is a potentially dangerous operation, so we must perform some checks:
- The remote URL is available
- The Content-Length is provided, and is not too large
- The file is a valid image file
Arguments:
remote_url: The remote URL to retrieve image
max_size: Maximum allowed image size (default = 1MB)
timeout: Connection timeout in seconds (default = 5)
Returns:
An in-memory PIL image file, if the download was successful
Raises:
requests.exceptions.ConnectionError: Connection could not be established
requests.exceptions.Timeout: Connection timed out
requests.exceptions.HTTPError: Server responded with invalid response code
ValueError: Server responded with invalid 'Content-Length' value
TypeError: Response is not a valid image
"""
# Check that the provided URL at least looks valid
validator = URLValidator()
validator(remote_url)
# Calculate maximum allowable image size (in bytes)
max_size = int(InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024
# Add user specified user-agent to request (if specified)
user_agent = InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT')
if user_agent:
headers = {"User-Agent": user_agent}
else:
headers = None
try:
response = requests.get(
remote_url,
timeout=timeout,
allow_redirects=True,
stream=True,
headers=headers,
)
# Throw an error if anything goes wrong
response.raise_for_status()
except requests.exceptions.ConnectionError as exc:
raise Exception(_("Connection error") + f": {str(exc)}")
except requests.exceptions.Timeout as exc:
raise exc
except requests.exceptions.HTTPError:
raise requests.exceptions.HTTPError(_("Server responded with invalid status code") + f": {response.status_code}")
except Exception as exc:
raise Exception(_("Exception occurred") + f": {str(exc)}")
if response.status_code != 200:
raise Exception(_("Server responded with invalid status code") + f": {response.status_code}")
try:
content_length = int(response.headers.get('Content-Length', 0))
except ValueError:
raise ValueError(_("Server responded with invalid Content-Length value"))
if content_length > max_size:
raise ValueError(_("Image size is too large"))
# Download the file, ensuring we do not exceed the reported size
fo = io.BytesIO()
dl_size = 0
chunk_size = 64 * 1024
for chunk in response.iter_content(chunk_size=chunk_size):
dl_size += len(chunk)
if dl_size > max_size:
raise ValueError(_("Image download exceeded maximum size"))
fo.write(chunk)
if dl_size == 0:
raise ValueError(_("Remote server returned empty response"))
# Now, attempt to convert the downloaded data to a valid image file
# img.verify() will throw an exception if the image is not valid
try:
img = Image.open(fo).convert()
img.verify()
except Exception:
raise TypeError(_("Supplied URL is not a valid image file"))
return img
def TestIfImage(img):
"""Test if an image file is indeed an image."""
try:
@@ -492,7 +632,7 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
serial = serial.strip()
# Ignore blank / empty serials
# Ignore blank / emtpy serials
if len(serial) == 0:
return
@@ -683,6 +823,75 @@ def validateFilterString(value, model=None):
return results
def addUserPermission(user, permission):
"""Shortcut function for adding a certain permission to a user."""
perm = Permission.objects.get(codename=permission)
user.user_permissions.add(perm)
def addUserPermissions(user, permissions):
"""Shortcut function for adding multiple permissions to a user."""
for permission in permissions:
addUserPermission(user, permission)
def getMigrationFileNames(app):
"""Return a list of all migration filenames for provided app."""
local_dir = Path(__file__).parent
files = local_dir.joinpath('..', app, 'migrations').iterdir()
# Regex pattern for migration files
regex = re.compile(r"^[\d]+_.*\.py$")
migration_files = []
for f in files:
if regex.match(f.name):
migration_files.append(f.name)
return migration_files
def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
"""Return the filename associated with the oldest migration."""
oldest_num = -1
oldest_file = None
for f in getMigrationFileNames(app):
if ignore_initial and f.startswith('0001_initial'):
continue
num = int(f.split('_')[0])
if oldest_file is None or num < oldest_num:
oldest_num = num
oldest_file = f
if exclude_extension:
oldest_file = oldest_file.replace('.py', '')
return oldest_file
def getNewestMigrationFile(app, exclude_extension=True):
"""Return the filename associated with the newest migration."""
newest_file = None
newest_num = -1
for f in getMigrationFileNames(app):
num = int(f.split('_')[0])
if newest_file is None or num > newest_num:
newest_num = num
newest_file = f
if exclude_extension:
newest_file = newest_file.replace('.py', '')
return newest_file
def clean_decimal(number):
"""Clean-up decimal value."""
# Check if empty
@@ -853,3 +1062,102 @@ def inheritors(cls):
subcls.add(child)
work.append(child)
return subcls
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
"""Testcase with user setup buildin."""
pass
def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None):
"""Notify all responsible parties of a change in an instance.
Parses the supplied content with the provided instance and sender and sends a notification to all responsible users,
excluding the optional excluded list.
Args:
instance: The newly created instance
sender: Sender model reference
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
exclude (User, optional): User instance that should be excluded. Defaults to None.
"""
if instance.responsible is not None:
# Setup context for notification parsing
content_context = {
'instance': str(instance),
'verbose_name': sender._meta.verbose_name,
'app_label': sender._meta.app_label,
'model_name': sender._meta.model_name,
}
# Setup notification context
context = {
'instance': instance,
'name': content.name.format(**content_context),
'message': content.message.format(**content_context),
'link': InvenTree.helpers.construct_absolute_url(instance.get_absolute_url()),
'template': {
'html': content.template.format(**content_context),
'subject': content.name.format(**content_context),
}
}
# Create notification
trigger_notification(
instance,
content.slug.format(**content_context),
targets=[instance.responsible],
target_exclude=[exclude],
context=context,
)
def render_currency(money, decimal_places=None, currency=None, include_symbol=True, min_decimal_places=None):
"""Render a currency / Money object to a formatted string (e.g. for reports)
Arguments:
money: The Money instance to be rendered
decimal_places: The number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
currency: Optionally convert to the specified currency
include_symbol: Render with the appropriate currency symbol
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
"""
if money in [None, '']:
return '-'
if type(money) is not Money:
return '-'
if currency is not None:
# Attempt to convert to the provided currency
# If cannot be done, leave the original
try:
money = convert_money(money, currency)
except Exception:
pass
if decimal_places is None:
decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6)
if min_decimal_places is None:
min_decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES_MIN', 0)
value = Decimal(str(money.amount)).normalize()
value = str(value)
if '.' in value:
decimals = len(value.split('.')[-1])
decimals = max(decimals, min_decimal_places)
decimals = min(decimals, decimal_places)
decimal_places = decimals
else:
decimal_places = max(decimal_places, 2)
return moneyed.localization.format_money(
money,
decimal_places=decimal_places,
include_symbol=include_symbol,
)

View File

@@ -1,302 +0,0 @@
"""Provides helper functions used throughout the InvenTree project that access the database."""
import io
import logging
from decimal import Decimal
from urllib.parse import urljoin
from django.conf import settings
from django.core.validators import URLValidator
from django.db.utils import OperationalError, ProgrammingError
from django.utils.translation import gettext_lazy as _
import moneyed.localization
import requests
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from PIL import Image
import common.models
import InvenTree
import InvenTree.helpers_model
import InvenTree.version
from common.notifications import (InvenTreeNotificationBodies,
NotificationBody, trigger_notification)
logger = logging.getLogger('inventree')
def getSetting(key, backup_value=None):
"""Shortcut for reading a setting value from the database."""
return common.models.InvenTreeSetting.get_setting(key, backup_value=backup_value)
def construct_absolute_url(*arg, **kwargs):
"""Construct (or attempt to construct) an absolute URL from a relative URL.
This is useful when (for example) sending an email to a user with a link
to something in the InvenTree web framework.
A URL is constructed in the following order:
1. If settings.SITE_URL is set (e.g. in the Django settings), use that
2. If the InvenTree setting INVENTREE_BASE_URL is set, use that
3. Otherwise, use the current request URL (if available)
"""
relative_url = '/'.join(arg)
# If a site URL is provided, use that
site_url = getattr(settings, 'SITE_URL', None)
if not site_url:
# Otherwise, try to use the InvenTree setting
try:
site_url = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False)
except (ProgrammingError, OperationalError):
pass
if not site_url:
# Otherwise, try to use the current request
request = kwargs.get('request', None)
if request:
site_url = request.build_absolute_uri('/')
if not site_url:
# No site URL available, return the relative URL
return relative_url
return urljoin(site_url, relative_url)
def get_base_url(**kwargs):
"""Return the base URL for the InvenTree server"""
return construct_absolute_url('', **kwargs)
def download_image_from_url(remote_url, timeout=2.5):
"""Download an image file from a remote URL.
This is a potentially dangerous operation, so we must perform some checks:
- The remote URL is available
- The Content-Length is provided, and is not too large
- The file is a valid image file
Arguments:
remote_url: The remote URL to retrieve image
max_size: Maximum allowed image size (default = 1MB)
timeout: Connection timeout in seconds (default = 5)
Returns:
An in-memory PIL image file, if the download was successful
Raises:
requests.exceptions.ConnectionError: Connection could not be established
requests.exceptions.Timeout: Connection timed out
requests.exceptions.HTTPError: Server responded with invalid response code
ValueError: Server responded with invalid 'Content-Length' value
TypeError: Response is not a valid image
"""
# Check that the provided URL at least looks valid
validator = URLValidator()
validator(remote_url)
# Calculate maximum allowable image size (in bytes)
max_size = int(common.models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024
# Add user specified user-agent to request (if specified)
user_agent = common.models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT')
if user_agent:
headers = {"User-Agent": user_agent}
else:
headers = None
try:
response = requests.get(
remote_url,
timeout=timeout,
allow_redirects=True,
stream=True,
headers=headers,
)
# Throw an error if anything goes wrong
response.raise_for_status()
except requests.exceptions.ConnectionError as exc:
raise Exception(_("Connection error") + f": {str(exc)}")
except requests.exceptions.Timeout as exc:
raise exc
except requests.exceptions.HTTPError:
raise requests.exceptions.HTTPError(_("Server responded with invalid status code") + f": {response.status_code}")
except Exception as exc:
raise Exception(_("Exception occurred") + f": {str(exc)}")
if response.status_code != 200:
raise Exception(_("Server responded with invalid status code") + f": {response.status_code}")
try:
content_length = int(response.headers.get('Content-Length', 0))
except ValueError:
raise ValueError(_("Server responded with invalid Content-Length value"))
if content_length > max_size:
raise ValueError(_("Image size is too large"))
# Download the file, ensuring we do not exceed the reported size
file = io.BytesIO()
dl_size = 0
chunk_size = 64 * 1024
for chunk in response.iter_content(chunk_size=chunk_size):
dl_size += len(chunk)
if dl_size > max_size:
raise ValueError(_("Image download exceeded maximum size"))
file.write(chunk)
if dl_size == 0:
raise ValueError(_("Remote server returned empty response"))
# Now, attempt to convert the downloaded data to a valid image file
# img.verify() will throw an exception if the image is not valid
try:
img = Image.open(file).convert()
img.verify()
except Exception:
raise TypeError(_("Supplied URL is not a valid image file"))
return img
def render_currency(money, decimal_places=None, currency=None, include_symbol=True, min_decimal_places=None, max_decimal_places=None):
"""Render a currency / Money object to a formatted string (e.g. for reports)
Arguments:
money: The Money instance to be rendered
decimal_places: The number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
currency: Optionally convert to the specified currency
include_symbol: Render with the appropriate currency symbol
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
"""
if money in [None, '']:
return '-'
if type(money) is not Money:
return '-'
if currency is not None:
# Attempt to convert to the provided currency
# If cannot be done, leave the original
try:
money = convert_money(money, currency)
except Exception:
pass
if decimal_places is None:
decimal_places = common.models.InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6)
if min_decimal_places is None:
min_decimal_places = common.models.InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES_MIN', 0)
if max_decimal_places is None:
max_decimal_places = common.models.InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6)
value = Decimal(str(money.amount)).normalize()
value = str(value)
if '.' in value:
decimals = len(value.split('.')[-1])
decimals = max(decimals, min_decimal_places)
decimals = min(decimals, decimal_places)
decimal_places = decimals
else:
decimal_places = max(decimal_places, 2)
decimal_places = max(decimal_places, max_decimal_places)
return moneyed.localization.format_money(
money,
decimal_places=decimal_places,
include_symbol=include_symbol,
)
def getModelsWithMixin(mixin_class) -> list:
"""Return a list of models that inherit from the given mixin class.
Args:
mixin_class: The mixin class to search for
Returns:
List of models that inherit from the given mixin class
"""
from django.contrib.contenttypes.models import ContentType
db_models = [x.model_class() for x in ContentType.objects.all() if x is not None]
return [x for x in db_models if x is not None and issubclass(x, mixin_class)]
def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None):
"""Notify all responsible parties of a change in an instance.
Parses the supplied content with the provided instance and sender and sends a notification to all responsible users,
excluding the optional excluded list.
Args:
instance: The newly created instance
sender: Sender model reference
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
exclude (User, optional): User instance that should be excluded. Defaults to None.
"""
notify_users([instance.responsible], instance, sender, content=content, exclude=exclude)
def notify_users(users, instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None):
"""Notify all passed users or groups.
Parses the supplied content with the provided instance and sender and sends a notification to all users,
excluding the optional excluded list.
Args:
users: List of users or groups to notify
instance: The newly created instance
sender: Sender model reference
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
exclude (User, optional): User instance that should be excluded. Defaults to None.
"""
# Setup context for notification parsing
content_context = {
'instance': str(instance),
'verbose_name': sender._meta.verbose_name,
'app_label': sender._meta.app_label,
'model_name': sender._meta.model_name,
}
# Setup notification context
context = {
'instance': instance,
'name': content.name.format(**content_context),
'message': content.message.format(**content_context),
'link': InvenTree.helpers_model.construct_absolute_url(instance.get_absolute_url()),
'template': {
'subject': content.name.format(**content_context),
}
}
if content.template:
context['template']['html'] = content.template.format(**content_context)
# Create notification
trigger_notification(
instance,
content.slug.format(**content_context),
targets=users,
target_exclude=[exclude],
context=context,
)

View File

@@ -23,8 +23,8 @@ def render_file(file_name, source, target, locales, ctx):
with open(target_file, 'w') as localised_file:
with lang_over(locale):
rendered = render_to_string(os.path.join(source, file_name), ctx)
localised_file.write(rendered)
renderd = render_to_string(os.path.join(source, file_name), ctx)
localised_file.write(renderd)
class Command(BaseCommand):

View File

@@ -18,7 +18,7 @@ class Command(BaseCommand):
while not connected:
time.sleep(2)
time.sleep(5)
try:
connection.ensure_connection()
@@ -34,4 +34,4 @@ class Command(BaseCommand):
self.stdout.write("Database configuration is not usable")
if connected:
self.stdout.write("Database connection successful!")
self.stdout.write("Database connection sucessful!")

View File

@@ -28,7 +28,7 @@ class InvenTreeMetadata(SimpleMetadata):
"""
def determine_metadata(self, request, view):
"""Overwrite the metadata to adapt to the request user."""
"""Overwrite the metadata to adapt to hte request user."""
self.request = request
self.view = view
@@ -36,7 +36,7 @@ class InvenTreeMetadata(SimpleMetadata):
"""
Custom context information to pass through to the OPTIONS endpoint,
if the "context=True" is supplied to the OPTIONS request
if the "context=True" is supplied to the OPTIONS requst
Serializer class can supply context data by defining a get_context_data() method (no arguments)
"""

View File

@@ -14,6 +14,7 @@ from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
from error_report.middleware import ExceptionProcessor
from rest_framework.authtoken.models import Token
from common.models import InvenTreeSetting
from InvenTree.urls import frontendpatterns
logger = logging.getLogger("inventree")
@@ -101,7 +102,7 @@ class AuthRequiredMiddleware(object):
'/static/',
]
if path not in urls and not any(path.startswith(p) for p in paths_ignore):
if path not in urls and not any([path.startswith(p) for p in paths_ignore]):
# Save the 'next' parameter to pass through to the login view
return redirect(f'{reverse_lazy("account_login")}?next={request.path}')
@@ -122,9 +123,6 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
"""Check if user is required to have MFA enabled."""
def require_2fa(self, request):
"""Use setting to check if MFA should be enforced for frontend page."""
from common.models import InvenTreeSetting
try:
if url_matcher.resolve(request.path[1:]):
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')
@@ -160,38 +158,11 @@ class InvenTreeExceptionProcessor(ExceptionProcessor):
"""Custom exception processor that respects blocked errors."""
def process_exception(self, request, exception):
"""Check if kind is ignored before processing."""
"""Check if kind is ignored before procesing."""
kind, info, data = sys.exc_info()
# Check if the error is on the ignore list
# Check if the eror is on the ignore list
if kind in settings.IGNORED_ERRORS:
return
import traceback
from django.views.debug import ExceptionReporter
from error_report.models import Error
from error_report.settings import ERROR_DETAIL_SETTINGS
# Error reporting is disabled
if not ERROR_DETAIL_SETTINGS.get('ERROR_DETAIL_ENABLE', True):
return
path = request.build_absolute_uri()
# Truncate the path to a reasonable length
# Otherwise we get a database error,
# because the path field is limited to 200 characters
if len(path) > 200:
path = path[:195] + '...'
error = Error.objects.create(
kind=kind.__name__,
html=ExceptionReporter(request, kind, info, data).get_traceback_html(),
path=path,
info=info,
data='\n'.join(traceback.format_exception(kind, info, data)),
)
error.save()
return super().process_exception(request, exception)

View File

@@ -125,7 +125,7 @@ class CreateAPI(CleanMixin, generics.CreateAPIView):
class RetrieveAPI(generics.RetrieveAPIView):
"""View for retrieve API."""
"""View for retreive API."""
pass

View File

@@ -21,10 +21,10 @@ from error_report.models import Error
from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey
import InvenTree.fields
import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
from common.models import InvenTreeSetting
from InvenTree.fields import InvenTreeURLField
from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree')
@@ -44,88 +44,13 @@ def rename_attachment(instance, filename):
return os.path.join(instance.getSubdir(), filename)
class MetadataMixin(models.Model):
"""Model mixin class which adds a JSON metadata field to a model, for use by any (and all) plugins.
The intent of this mixin is to provide a metadata field on a model instance,
for plugins to read / modify as required, to store any extra information.
The assumptions for models implementing this mixin are:
- The internal InvenTree business logic will make no use of this field
- Multiple plugins may read / write to this metadata field, and not assume they have sole rights
"""
class Meta:
"""Meta for MetadataMixin."""
abstract = True
def save(self, *args, **kwargs):
"""Save the model instance, and perform validation on the metadata field."""
self.validate_metadata()
super().save(*args, **kwargs)
def clean(self, *args, **kwargs):
"""Perform model validation on the metadata field."""
super().clean()
self.validate_metadata()
def validate_metadata(self):
"""Validate the metadata field."""
# Ensure that the 'metadata' field is a valid dict object
if self.metadata is None:
self.metadata = {}
if type(self.metadata) is not dict:
raise ValidationError({'metadata': _('Metadata must be a python dict object')})
metadata = models.JSONField(
blank=True, null=True,
verbose_name=_('Plugin Metadata'),
help_text=_('JSON metadata field, for use by external plugins'),
)
def get_metadata(self, key: str, backup_value=None):
"""Finds metadata for this model instance, using the provided key for lookup.
Args:
key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used
Returns:
Python dict object containing requested metadata. If no matching metadata is found, returns None
"""
if self.metadata is None:
return backup_value
return self.metadata.get(key, backup_value)
def set_metadata(self, key: str, data, commit: bool = True, overwrite: bool = False):
"""Save the provided metadata under the provided key.
Args:
key (str): Key for saving metadata
data (Any): Data object to save - must be able to be rendered as a JSON string
commit (bool, optional): If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted. Defaults to True.
overwrite (bool): If true, delete existing metadata before adding new value
"""
if overwrite or self.metadata is None:
self.metadata = {}
self.metadata[key] = data
if commit:
self.save()
class DataImportMixin(object):
"""Model mixin class which provides support for 'data import' functionality.
Models which implement this mixin should provide information on the fields available for import
"""
# Define a map of fields available for import
# Define a map of fields avaialble for import
IMPORT_FIELDS = {}
@classmethod
@@ -207,8 +132,6 @@ class ReferenceIndexingMixin(models.Model):
if cls.REFERENCE_PATTERN_SETTING is None:
return ''
# import at function level to prevent cyclic imports
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting(cls.REFERENCE_PATTERN_SETTING, create=False).strip()
@classmethod
@@ -488,7 +411,7 @@ class InvenTreeAttachment(models.Model):
blank=True, null=True
)
link = InvenTree.fields.InvenTreeURLField(
link = InvenTreeURLField(
blank=True, null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL')
@@ -712,12 +635,12 @@ class InvenTreeTree(MPTTModel):
available = contents.get_all_objects_for_this_type()
# List of child IDs
children = self.getUniqueChildren()
childs = self.getUniqueChildren()
acceptable = [None]
for a in available:
if a.id not in children:
if a.id not in childs:
acceptable.append(a)
return acceptable
@@ -729,7 +652,7 @@ class InvenTreeTree(MPTTModel):
Returns:
List of category names from the top level to the parent of this category
"""
return list(self.get_ancestors())
return [a for a in self.get_ancestors()]
@property
def path(self):
@@ -747,27 +670,6 @@ class InvenTreeTree(MPTTModel):
return "{path} - {desc}".format(path=self.pathstring, desc=self.description)
class InvenTreeNotesMixin(models.Model):
"""A mixin class for adding notes functionality to a model class.
The following fields are added to any model which implements this mixin:
- notes : A text field for storing notes
"""
class Meta:
"""Metaclass options for this mixin.
Note: abstract must be true, as this is only a mixin, not a separate table
"""
abstract = True
notes = InvenTree.fields.InvenTreeNotesField(
verbose_name=_('Notes'),
help_text=_('Markdown notes (optional)'),
)
class InvenTreeBarcodeMixin(models.Model):
"""A mixin class for adding barcode functionality to a model class.
@@ -891,7 +793,7 @@ def after_error_logged(sender, instance: Error, created: bool, **kwargs):
users = get_user_model().objects.filter(is_staff=True)
link = InvenTree.helpers_model.construct_absolute_url(
link = InvenTree.helpers.construct_absolute_url(
reverse('admin:error_report_error_change', kwargs={'object_id': instance.pk})
)
@@ -907,7 +809,7 @@ def after_error_logged(sender, instance: Error, created: bool, **kwargs):
'inventree.error_log',
context=context,
targets=users,
delivery_methods={common.notifications.UIMessageNotification, },
delivery_methods=set([common.notifications.UIMessageNotification]),
)
except Exception as exc:

View File

@@ -92,14 +92,6 @@ class IsSuperuser(permissions.IsAdminUser):
return bool(request.user and request.user.is_superuser)
class IsStaffOrReadOnly(permissions.IsAdminUser):
"""Allows read-only access to any user, but write access is restricted to staff users."""
def has_permission(self, request, view):
"""Check if the user is a superuser."""
return bool(request.user and request.user.is_staff or request.method in permissions.SAFE_METHODS)
def auth_exempt(view_func):
"""Mark a view function as being exempt from auth requirements."""
def wrapped_view(*args, **kwargs):

View File

@@ -13,11 +13,6 @@ def isImportingData():
return 'loaddata' in sys.argv
def isRunningMigrations():
"""Return True if the database is currently running migrations."""
return 'migrate' in sys.argv or 'makemigrations' in sys.argv
def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False, allow_shell: bool = False):
"""Returns True if the apps.py file can access database records.

View File

@@ -43,7 +43,7 @@ ALLOWED_ATTRIBUTES_SVG = [
]
def sanitize_svg(file_data, strip: bool = True, elements: str = ALLOWED_ELEMENTS_SVG, attributes: str = ALLOWED_ATTRIBUTES_SVG) -> str:
def sanitize_svg(file_data: str, strip: bool = True, elements: str = ALLOWED_ELEMENTS_SVG, attributes: str = ALLOWED_ATTRIBUTES_SVG) -> str:
"""Sanatize a SVG file.
Args:
@@ -56,10 +56,6 @@ def sanitize_svg(file_data, strip: bool = True, elements: str = ALLOWED_ELEMENTS
str: Sanitzied SVG file.
"""
# Handle byte-encoded data
if type(file_data) == bytes:
file_data = file_data.decode('utf-8')
cleaned = clean(
file_data,
tags=elements,
@@ -68,5 +64,4 @@ def sanitize_svg(file_data, strip: bool = True, elements: str = ALLOWED_ELEMENTS
strip_comments=strip,
css_sanitizer=CSSSanitizer()
)
return cleaned

View File

@@ -1,68 +0,0 @@
"""Configuration for Sentry.io error reporting."""
import logging
from django.conf import settings
from django.core.exceptions import ValidationError
from django.http import Http404
import rest_framework.exceptions
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from InvenTree.version import INVENTREE_SW_VERSION
logger = logging.getLogger('inventree')
def default_sentry_dsn():
"""Return the default Sentry.io DSN for InvenTree"""
return 'https://3928ccdba1d34895abde28031fd00100@o378676.ingest.sentry.io/6494600'
def sentry_ignore_errors():
"""Return a list of error types to ignore.
These error types will *not* be reported to sentry.io.
"""
return [
Http404,
ValidationError,
rest_framework.exceptions.AuthenticationFailed,
rest_framework.exceptions.PermissionDenied,
rest_framework.exceptions.ValidationError,
]
def init_sentry(dsn, sample_rate, tags):
"""Initialize sentry.io error reporting"""
logger.info("Initializing sentry.io integration")
sentry_sdk.init(
dsn=dsn,
integrations=[DjangoIntegration()],
traces_sample_rate=sample_rate,
send_default_pii=True,
ignore_errors=sentry_ignore_errors(),
release=INVENTREE_SW_VERSION,
)
for key, val in tags.items():
sentry_sdk.set_tag(f'inventree_{key}', val)
def report_exception(exc):
"""Report an exception to sentry.io"""
if settings.SENTRY_ENABLED and settings.SENTRY_DSN:
if not any(isinstance(exc, e) for e in sentry_ignore_errors()):
logger.info(f"Reporting exception to sentry.io: {exc}")
try:
sentry_sdk.capture_exception(exc)
except Exception:
logger.warning("Failed to report exception to sentry.io")

View File

@@ -19,12 +19,11 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import empty
from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer
import common.models as common_models
from common.models import InvenTreeSetting
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers_model import download_image_from_url
from InvenTree.helpers import download_image_from_url
class InvenTreeMoneySerializer(MoneyField):
@@ -34,7 +33,7 @@ class InvenTreeMoneySerializer(MoneyField):
"""
def __init__(self, *args, **kwargs):
"""Override default values."""
"""Overrite default values."""
kwargs["max_digits"] = kwargs.get("max_digits", 19)
self.decimal_places = kwargs["decimal_places"] = kwargs.get("decimal_places", 6)
kwargs["required"] = kwargs.get("required", False)
@@ -74,17 +73,10 @@ class InvenTreeCurrencySerializer(serializers.ChoiceField):
def __init__(self, *args, **kwargs):
"""Initialize the currency serializer"""
choices = currency_code_mappings()
allow_blank = kwargs.get('allow_blank', False) or kwargs.get('allow_null', False)
if allow_blank:
choices = [('', '---------')] + choices
kwargs['choices'] = choices
kwargs['choices'] = currency_code_mappings()
if 'default' not in kwargs and 'required' not in kwargs:
kwargs['default'] = '' if allow_blank else currency_code_default
kwargs['default'] = currency_code_default
if 'label' not in kwargs:
kwargs['label'] = _('Currency')
@@ -265,28 +257,6 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return data
class InvenTreeTaggitSerializer(TaggitSerializer):
"""Updated from https://github.com/glemmaPaul/django-taggit-serializer."""
def update(self, instance, validated_data):
"""Overridden update method to re-add the tagmanager."""
to_be_tagged, validated_data = self._pop_tags(validated_data)
tag_object = super().update(instance, validated_data)
for key in to_be_tagged.keys():
# re-add the tagmanager
new_tagobject = tag_object.__class__.objects.get(id=tag_object.id)
setattr(tag_object, key, getattr(new_tagobject, key))
return self._save_tags(tag_object, to_be_tagged)
class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSerializer):
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
pass
class UserSerializer(InvenTreeModelSerializer):
"""Serializer for a User."""
@@ -522,7 +492,7 @@ class DataFileUploadSerializer(serializers.Serializer):
pass
# Extract a list of valid model field names
model_field_names = list(model_fields.keys())
model_field_names = [key for key in model_fields.keys()]
# Provide a dict of available columns from the dataset
file_columns = {}
@@ -724,7 +694,7 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
if not url:
return
if not common_models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL'):
if not InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL'):
raise ValidationError(_("Downloading images from remote URL is not enabled"))
try:

View File

@@ -17,18 +17,15 @@ from pathlib import Path
import django.conf.locale
import django.core.exceptions
from django.core.validators import URLValidator
from django.http import Http404
from django.utils.translation import gettext_lazy as _
import moneyed
from dotenv import load_dotenv
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
from InvenTree.sentry import default_sentry_dsn, init_sentry
from InvenTree.version import inventreeApiVersion
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from . import config
from .config import get_boolean_setting, get_custom_file, get_setting
INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
@@ -66,12 +63,6 @@ BASE_DIR = config.get_base_dir()
# Load configuration data
CONFIG = config.load_config_data(set_cache=True)
# Load VERSION data if it exists
version_file = BASE_DIR.parent.joinpath('VERSION')
if version_file.exists():
print('load version from file')
load_dotenv(version_file)
# Default action is to run the system in Debug mode
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', True)
@@ -203,7 +194,6 @@ INSTALLED_APPS = [
'stock.apps.StockConfig',
'users.apps.UsersConfig',
'plugin.apps.PluginAppConfig',
'generic',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
# Core django modules
@@ -233,8 +223,6 @@ INSTALLED_APPS = [
'django_q',
'formtools', # Form wizard tools
'dbbackup', # Backups - django-dbbackup
'taggit', # Tagging
'flags', # Flagging - django-flags
'allauth', # Base app for SSO
'allauth.account', # Extend user with accounts
@@ -245,9 +233,6 @@ INSTALLED_APPS = [
'django_otp.plugins.otp_static', # Backup codes
'allauth_2fa', # MFA flow for allauth
'dj_rest_auth', # Authentication APIs - dj-rest-auth
'dj_rest_auth.registration', # Registration APIs - dj-rest-auth'
'drf_spectacular', # API documentation
'django_ical', # For exporting calendars
]
@@ -341,7 +326,7 @@ TEMPLATES = [
'InvenTree.context.user_roles',
],
'loaders': [(
'InvenTree.template.InvenTreeTemplateLoader', [
'django.template.loaders.cached.Loader', [
'plugin.template.PluginTemplateLoader',
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
@@ -371,7 +356,7 @@ REST_FRAMEWORK = {
'rest_framework.permissions.DjangoModelPermissions',
'InvenTree.permissions.RolePermission',
),
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
@@ -382,32 +367,6 @@ if DEBUG:
# Enable browsable API if in DEBUG mode
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer')
# dj-rest-auth
# JWT switch
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
REST_USE_JWT = USE_JWT
OLD_PASSWORD_FIELD_ENABLED = True
REST_AUTH_REGISTER_SERIALIZERS = {'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'}
# JWT settings - rest_framework_simplejwt
if USE_JWT:
JWT_AUTH_COOKIE = 'inventree-auth'
JWT_AUTH_REFRESH_COOKIE = 'inventree-token'
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] + (
'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
)
INSTALLED_APPS.append('rest_framework_simplejwt')
# WSGI default setting
SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'},
'EXTERNAL_DOCS': {'docs': 'https://docs.inventree.org', 'web': 'https://inventree.org'},
'VERSION': inventreeApiVersion(),
'SERVE_INCLUDE_SCHEMA': False,
}
WSGI_APPLICATION = 'InvenTree.wsgi.application'
"""
@@ -508,7 +467,7 @@ if "postgres" in db_engine: # pragma: no cover
if "connect_timeout" not in db_options:
# The DB server is in the same data center, it should not take very
# long to connect to the database server
# # seconds, 2 is minimum allowed by libpq
# # seconds, 2 is minium allowed by libpq
db_options["connect_timeout"] = int(
get_setting('INVENTREE_DB_TIMEOUT', 'database.timeout', 2)
)
@@ -601,25 +560,31 @@ DATABASES = {
REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False)
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER')
LOGIN_REDIRECT_URL = "/index/"
# sentry.io integration for error reporting
SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False)
# Default Sentry DSN (can be overridden if user wants custom sentry integration)
SENTRY_DSN = get_setting('INVENTREE_SENTRY_DSN', 'sentry_dsn', default_sentry_dsn())
# Default Sentry DSN (can be overriden if user wants custom sentry integration)
INVENTREE_DSN = 'https://3928ccdba1d34895abde28031fd00100@o378676.ingest.sentry.io/6494600'
SENTRY_DSN = get_setting('INVENTREE_SENTRY_DSN', 'sentry_dsn', INVENTREE_DSN)
SENTRY_SAMPLE_RATE = float(get_setting('INVENTREE_SENTRY_SAMPLE_RATE', 'sentry_sample_rate', 0.1))
if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover
logger.info("Running with sentry.io integration enabled")
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[DjangoIntegration(), ],
traces_sample_rate=1.0 if DEBUG else SENTRY_SAMPLE_RATE,
send_default_pii=True
)
inventree_tags = {
'testing': TESTING,
'docker': DOCKER,
'debug': DEBUG,
'remote': REMOTE_LOGIN,
}
init_sentry(SENTRY_DSN, SENTRY_SAMPLE_RATE, inventree_tags)
for key, val in inventree_tags.items():
sentry_sdk.set_tag(f'inventree_{key}', val)
# Cache configuration
cache_host = get_setting('INVENTREE_CACHE_HOST', 'cache.host', None)
@@ -628,7 +593,7 @@ cache_port = get_setting('INVENTREE_CACHE_PORT', 'cache.port', '6379', typecast=
if cache_host: # pragma: no cover
# We are going to rely upon a possibly non-localhost for our cache,
# so don't wait too long for the cache as nothing in the cache should be
# irreplaceable.
# irreplacable.
_cache_options = {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")),
@@ -748,7 +713,6 @@ LANGUAGES = [
('es', _('Spanish')),
('es-mx', _('Spanish (Mexican)')),
('fa', _('Farsi / Persian')),
('fi', _('Finnish')),
('fr', _('French')),
('he', _('Hebrew')),
('hu', _('Hungarian')),
@@ -759,14 +723,14 @@ LANGUAGES = [
('no', _('Norwegian')),
('pl', _('Polish')),
('pt', _('Portuguese')),
('pt-br', _('Portuguese (Brazilian)')),
('pt-BR', _('Portuguese (Brazilian)')),
('ru', _('Russian')),
('sl', _('Slovenian')),
('sv', _('Swedish')),
('th', _('Thai')),
('tr', _('Turkish')),
('vi', _('Vietnamese')),
('zh-hans', _('Chinese (Simplified)')),
('zh-hans', _('Chinese')),
]
# Testing interface translations
@@ -795,11 +759,6 @@ CURRENCIES = get_setting(
typecast=list,
)
# Ensure that at least one currency value is available
if len(CURRENCIES) == 0: # pragma: no cover
logger.warning("No currencies selected: Defaulting to USD")
CURRENCIES = ['USD']
# Maximum number of decimal places for currency rendering
CURRENCY_DECIMAL_PLACES = 6
@@ -824,10 +783,6 @@ EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False)
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '')
# If "from" email not specified, default to the username
if not DEFAULT_FROM_EMAIL:
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '')
EMAIL_USE_LOCALTIME = False
EMAIL_TIMEOUT = 60
@@ -877,7 +832,7 @@ ACCOUNT_PREVENT_ENUMERATION = True
# override forms / adapters
ACCOUNT_FORMS = {
'login': 'InvenTree.forms.CustomLoginForm',
'login': 'allauth.account.forms.LoginForm',
'signup': 'InvenTree.forms.CustomSignupForm',
'add_email': 'allauth.account.forms.AddEmailForm',
'change_password': 'allauth.account.forms.ChangePasswordForm',
@@ -944,22 +899,12 @@ PLUGINS_ENABLED = get_boolean_setting('INVENTREE_PLUGINS_ENABLED', 'plugins_enab
PLUGIN_FILE = config.get_plugin_file()
# Plugin test settings
PLUGIN_TESTING = get_setting('INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING) # Are plugins being tested?
PLUGIN_TESTING = get_setting('INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING) # Are plugins beeing tested?
PLUGIN_TESTING_SETUP = get_setting('INVENTREE_PLUGIN_TESTING_SETUP', 'PLUGIN_TESTING_SETUP', False) # Load plugins from setup hooks in testing?
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
PLUGIN_RETRY = get_setting('INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 5) # How often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# Site URL can be specified statically, or via a run-time setting
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
if SITE_URL:
logger.info(f"Site URL: {SITE_URL}")
# Check that the site URL is valid
validator = URLValidator()
validator(SITE_URL)
# User interface customization values
CUSTOM_LOGO = get_custom_file('INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True)
CUSTOM_SPLASH = get_custom_file('INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash')
@@ -970,23 +915,3 @@ if DEBUG:
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
# Flags
FLAGS = {
'EXPERIMENTAL': [
{'condition': 'boolean', 'value': DEBUG},
{'condition': 'parameter', 'value': 'experimental='},
], # Should experimental features be turned on?
'NEXT_GEN': [
{'condition': 'parameter', 'value': 'ngen='},
], # Should next-gen features be turned on?
}
# Get custom flags from environment/yaml
CUSTOM_FLAGS = get_setting('INVENTREE_FLAGS', 'flags', None, typecast=dict)
if CUSTOM_FLAGS:
if not isinstance(CUSTOM_FLAGS, dict):
logger.error(f"Invalid custom flags, must be valid dict: {CUSTOM_FLAGS}")
else:
logger.info(f"Custom flags: {CUSTOM_FLAGS}")
FLAGS.update(CUSTOM_FLAGS)

View File

@@ -1,127 +0,0 @@
"""API endpoints for social authentication with allauth."""
import logging
from importlib import import_module
from django.urls import include, path, reverse
from allauth.socialaccount import providers
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers.keycloak.views import \
KeycloakOAuth2Adapter
from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter,
OAuth2LoginView)
from rest_framework.generics import ListAPIView
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from common.models import InvenTreeSetting
logger = logging.getLogger('inventree')
class GenericOAuth2ApiLoginView(OAuth2LoginView):
"""Api view to login a user with a social account"""
def dispatch(self, request, *args, **kwargs):
"""Dispatch the regular login view directly."""
return self.login(request, *args, **kwargs)
class GenericOAuth2ApiConnectView(GenericOAuth2ApiLoginView):
"""Api view to connect a social account to the current user"""
def dispatch(self, request, *args, **kwargs):
"""Dispatch the connect request directly."""
# Override the request method be in connection mode
request.GET = request.GET.copy()
request.GET['process'] = 'connect'
# Resume the dispatch
return super().dispatch(request, *args, **kwargs)
def handle_oauth2(adapter: OAuth2Adapter):
"""Define urls for oauth2 endpoints."""
return [
path('login/', GenericOAuth2ApiLoginView.adapter_view(adapter), name=f'{provider.id}_api_login'),
path('connect/', GenericOAuth2ApiConnectView.adapter_view(adapter), name=f'{provider.id}_api_connect'),
]
def handle_keycloak():
"""Define urls for keycloak."""
return [
path('login/', GenericOAuth2ApiLoginView.adapter_view(KeycloakOAuth2Adapter), name='keycloak_api_login'),
path('connect/', GenericOAuth2ApiConnectView.adapter_view(KeycloakOAuth2Adapter), name='keycloak_api_connet'),
]
legacy = {
'twitter': 'twitter_oauth2',
'bitbucket': 'bitbucket_oauth2',
'linkedin': 'linkedin_oauth2',
'vimeo': 'vimeo_oauth2',
'openid': 'openid_connect',
} # legacy connectors
# Collect urls for all loaded providers
social_auth_urlpatterns = []
provider_urlpatterns = []
for provider in providers.registry.get_list():
try:
prov_mod = import_module(provider.get_package() + ".views")
except ImportError:
continue
# Try to extract the adapter class
adapters = [cls for cls in prov_mod.__dict__.values() if isinstance(cls, type) and not cls == OAuth2Adapter and issubclass(cls, OAuth2Adapter)]
# Get urls
urls = []
if len(adapters) == 1:
urls = handle_oauth2(adapter=adapters[0])
else:
if provider.id in legacy:
logger.warning(f'`{provider.id}` is not supported on platform UI. Use `{legacy[provider.id]}` instead.')
continue
elif provider.id == 'keycloak':
urls = handle_keycloak()
else:
logger.error(f'Found handler that is not yet ready for platform UI: `{provider.id}`. Open an feature request on GitHub if you need it implemented.')
continue
provider_urlpatterns += [path(f'{provider.id}/', include(urls))]
social_auth_urlpatterns += provider_urlpatterns
class SocialProvierListView(ListAPIView):
"""List of available social providers."""
permission_classes = (AllowAny,)
def get(self, request, *args, **kwargs):
"""Get the list of providers."""
provider_list = []
for provider in providers.registry.get_list():
provider_data = {
'id': provider.id,
'name': provider.name,
'login': request.build_absolute_uri(reverse(f'{provider.id}_api_login')),
'connect': request.build_absolute_uri(reverse(f'{provider.id}_api_connect')),
}
try:
provider_data['display_name'] = provider.get_app(request).name
except SocialApp.DoesNotExist:
provider_data['display_name'] = provider.name
provider_list.append(provider_data)
data = {
'sso_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO'),
'sso_registration': InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'),
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
'providers': provider_list
}
return Response(data)

View File

@@ -105,10 +105,6 @@ main {
font-size: 110%;
}
.bg-qr-code {
background-color: #FFF !important;
}
.qr-code {
max-width: 400px;
max-height: 400px;
@@ -223,7 +219,8 @@ main {
}
.sub-table {
margin-left: 60px;
margin-left: 45px;
margin-right: 45px;
}
.detail-icon .glyphicon {
@@ -269,6 +266,10 @@ main {
}
/* Styles for table buttons and filtering */
.button-toolbar .btn {
margin-left: 1px;
margin-right: 1px;
}
.filter-list {
display: inline-block;
@@ -300,14 +301,16 @@ main {
.filter-tag {
display: inline-block;
*display: inline;
margin: 5px;
padding: 5px;
padding-top: 1px;
padding-bottom: 1px;
color: var(--bs-body-color);
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--secondary-color);
zoom: 1;
padding-top: 3px;
padding-left: 3px;
padding-right: 3px;
border: 1px solid #aaa;
border-radius: 3px;
background: #eee;
margin: 1px;
margin-left: 5px;
margin-right: 5px;
white-space: nowrap;
}
@@ -318,6 +321,7 @@ main {
.filter-input {
display: inline-block;
*display: inline;
zoom: 1;
}
.filter-tag:hover {
@@ -1090,10 +1094,4 @@ a {
.sso-provider-link a {
width: 100%;
text-align: left;
}
.flex-cell {
display: flex;
align-items: center;
justify-content: space-between;
}
}

View File

@@ -4,13 +4,13 @@
import logging
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_q.models import Success
from django_q.monitor import Stat
import InvenTree.email
import InvenTree.ready
logger = logging.getLogger("inventree")
@@ -38,14 +38,38 @@ def is_worker_running(**kwargs):
)
# If any results are returned, then the background worker is running!
try:
result = results.exists()
except Exception:
# We may throw an exception if the database is not ready,
# or if the django_q table is not yet created (i.e. in CI testing)
result = False
return results.exists()
return result
def is_email_configured():
"""Check if email backend is configured.
NOTE: This does not check if the configuration is valid!
"""
configured = True
if InvenTree.ready.isInTestMode():
return False
if InvenTree.ready.isImportingData():
return False
if not settings.EMAIL_HOST:
configured = False
# Display warning unless in test mode
if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST is not configured")
# Display warning unless in test mode
if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST_USER is not configured")
# Display warning unless in test mode
if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST_PASSWORD is not configured")
return configured
def check_system_health(**kwargs):
@@ -67,7 +91,7 @@ def check_system_health(**kwargs):
result = False
logger.warning(_("Background worker check failed"))
if not InvenTree.email.is_email_configured(): # pragma: no cover
if not is_email_configured(): # pragma: no cover
result = False
logger.warning(_("Email backend not configured"))

View File

@@ -2,161 +2,374 @@
from django.utils.translation import gettext_lazy as _
from generic.states import StatusCode
class StatusCode:
"""Base class for representing a set of StatusCodes.
This is used to map a set of integer values to text.
"""
colors = {}
@classmethod
def render(cls, key, large=False):
"""Render the value as a HTML label."""
# If the key cannot be found, pass it back
if key not in cls.options.keys():
return key
value = cls.options.get(key, key)
color = cls.colors.get(key, 'secondary')
span_class = f'badge rounded-pill bg-{color}'
return "<span class='{cl}'>{value}</span>".format(
cl=span_class,
value=value
)
@classmethod
def list(cls):
"""Return the StatusCode options as a list of mapped key / value items."""
return list(cls.dict().values())
@classmethod
def text(cls, key):
"""Text for supplied status code."""
return cls.options.get(key, None)
@classmethod
def items(cls):
"""All status code items."""
return cls.options.items()
@classmethod
def keys(cls):
"""All status code keys."""
return cls.options.keys()
@classmethod
def labels(cls):
"""All status code labels."""
return cls.options.values()
@classmethod
def names(cls):
"""Return a map of all 'names' of status codes in this class
Will return a dict object, with the attribute name indexed to the integer value.
e.g.
{
'PENDING': 10,
'IN_PROGRESS': 20,
}
"""
keys = cls.keys()
status_names = {}
for d in dir(cls):
if d.startswith('_'):
continue
if d != d.upper():
continue
value = getattr(cls, d, None)
if value is None:
continue
if callable(value):
continue
if type(value) != int:
continue
if value not in keys:
continue
status_names[d] = value
return status_names
@classmethod
def dict(cls):
"""Return a dict representation containing all required information"""
values = {}
for name, value, in cls.names().items():
entry = {
'key': value,
'name': name,
'label': cls.label(value),
}
if hasattr(cls, 'colors'):
if color := cls.colors.get(value, None):
entry['color'] = color
values[name] = entry
return values
@classmethod
def label(cls, value):
"""Return the status code label associated with the provided value."""
return cls.options.get(value, value)
@classmethod
def value(cls, label):
"""Return the value associated with the provided label."""
for k in cls.options.keys():
if cls.options[k].lower() == label.lower():
return k
raise ValueError("Label not found")
class PurchaseOrderStatus(StatusCode):
"""Defines a set of status codes for a PurchaseOrder."""
# Order status codes
PENDING = 10, _("Pending"), 'secondary' # Order is pending (not yet placed)
PLACED = 20, _("Placed"), 'primary' # Order has been placed with supplier
COMPLETE = 30, _("Complete"), 'success' # Order has been completed
CANCELLED = 40, _("Cancelled"), 'danger' # Order was cancelled
LOST = 50, _("Lost"), 'warning' # Order was lost
RETURNED = 60, _("Returned"), 'warning' # Order was returned
PENDING = 10 # Order is pending (not yet placed)
PLACED = 20 # Order has been placed with supplier
COMPLETE = 30 # Order has been completed
CANCELLED = 40 # Order was cancelled
LOST = 50 # Order was lost
RETURNED = 60 # Order was returned
options = {
PENDING: _("Pending"),
PLACED: _("Placed"),
COMPLETE: _("Complete"),
CANCELLED: _("Cancelled"),
LOST: _("Lost"),
RETURNED: _("Returned"),
}
class PurchaseOrderStatusGroups:
"""Groups for PurchaseOrderStatus codes."""
colors = {
PENDING: 'secondary',
PLACED: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
LOST: 'warning',
RETURNED: 'warning',
}
# Open orders
OPEN = [
PurchaseOrderStatus.PENDING.value,
PurchaseOrderStatus.PLACED.value,
PENDING,
PLACED,
]
# Failed orders
FAILED = [
PurchaseOrderStatus.CANCELLED.value,
PurchaseOrderStatus.LOST.value,
PurchaseOrderStatus.RETURNED.value
CANCELLED,
LOST,
RETURNED
]
class SalesOrderStatus(StatusCode):
"""Defines a set of status codes for a SalesOrder."""
PENDING = 10, _("Pending"), 'secondary' # Order is pending
IN_PROGRESS = 15, _("In Progress"), 'primary' # Order has been issued, and is in progress
SHIPPED = 20, _("Shipped"), 'success' # Order has been shipped to customer
CANCELLED = 40, _("Cancelled"), 'danger' # Order has been cancelled
LOST = 50, _("Lost"), 'warning' # Order was lost
RETURNED = 60, _("Returned"), 'warning' # Order was returned
PENDING = 10 # Order is pending
IN_PROGRESS = 15 # Order has been issued, and is in progress
SHIPPED = 20 # Order has been shipped to customer
CANCELLED = 40 # Order has been cancelled
LOST = 50 # Order was lost
RETURNED = 60 # Order was returned
options = {
PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
SHIPPED: _("Shipped"),
CANCELLED: _("Cancelled"),
LOST: _("Lost"),
RETURNED: _("Returned"),
}
class SalesOrderStatusGroups:
"""Groups for SalesOrderStatus codes."""
colors = {
PENDING: 'secondary',
IN_PROGRESS: 'primary',
SHIPPED: 'success',
CANCELLED: 'danger',
LOST: 'warning',
RETURNED: 'warning',
}
# Open orders
OPEN = [
SalesOrderStatus.PENDING.value,
SalesOrderStatus.IN_PROGRESS.value,
PENDING,
IN_PROGRESS,
]
# Completed orders
COMPLETE = [
SalesOrderStatus.SHIPPED.value,
SHIPPED,
]
class StockStatus(StatusCode):
"""Status codes for Stock."""
OK = 10, _("OK"), 'success' # Item is OK
ATTENTION = 50, _("Attention needed"), 'warning' # Item requires attention
DAMAGED = 55, _("Damaged"), 'warning' # Item is damaged
DESTROYED = 60, _("Destroyed"), 'danger' # Item is destroyed
REJECTED = 65, _("Rejected"), 'danger' # Item is rejected
LOST = 70, _("Lost"), 'dark' # Item has been lost
QUARANTINED = 75, _("Quarantined"), 'info' # Item has been quarantined and is unavailable
RETURNED = 85, _("Returned"), 'warning' # Item has been returned from a customer
OK = 10 # Item is OK
ATTENTION = 50 # Item requires attention
DAMAGED = 55 # Item is damaged
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
options = {
OK: _("OK"),
ATTENTION: _("Attention needed"),
DAMAGED: _("Damaged"),
DESTROYED: _("Destroyed"),
LOST: _("Lost"),
REJECTED: _("Rejected"),
QUARANTINED: _("Quarantined"),
RETURNED: _("Returned"),
}
class StockStatusGroups:
"""Groups for StockStatus codes."""
colors = {
OK: 'success',
ATTENTION: 'warning',
DAMAGED: 'danger',
DESTROYED: 'danger',
LOST: 'dark',
REJECTED: 'danger',
QUARANTINED: 'info'
}
# The following codes correspond to parts that are 'available' or 'in stock'
AVAILABLE_CODES = [
StockStatus.OK.value,
StockStatus.ATTENTION.value,
StockStatus.DAMAGED.value,
StockStatus.RETURNED.value,
OK,
ATTENTION,
DAMAGED,
RETURNED,
]
class StockHistoryCode(StatusCode):
"""Status codes for StockHistory."""
LEGACY = 0, _('Legacy stock tracking entry')
LEGACY = 0
CREATED = 1, _('Stock item created')
CREATED = 1
# Manual editing operations
EDITED = 5, _('Edited stock item')
ASSIGNED_SERIAL = 6, _('Assigned serial number')
EDITED = 5
ASSIGNED_SERIAL = 6
# Manual stock operations
STOCK_COUNT = 10, _('Stock counted')
STOCK_ADD = 11, _('Stock manually added')
STOCK_REMOVE = 12, _('Stock manually removed')
STOCK_COUNT = 10
STOCK_ADD = 11
STOCK_REMOVE = 12
# Location operations
STOCK_MOVE = 20, _('Location changed')
STOCK_UPDATE = 25, _('Stock updated')
STOCK_MOVE = 20
# Installation operations
INSTALLED_INTO_ASSEMBLY = 30, _('Installed into assembly')
REMOVED_FROM_ASSEMBLY = 31, _('Removed from assembly')
INSTALLED_INTO_ASSEMBLY = 30
REMOVED_FROM_ASSEMBLY = 31
INSTALLED_CHILD_ITEM = 35, _('Installed component item')
REMOVED_CHILD_ITEM = 36, _('Removed component item')
INSTALLED_CHILD_ITEM = 35
REMOVED_CHILD_ITEM = 36
# Stock splitting operations
SPLIT_FROM_PARENT = 40, _('Split from parent item')
SPLIT_CHILD_ITEM = 42, _('Split child item')
SPLIT_FROM_PARENT = 40
SPLIT_CHILD_ITEM = 42
# Stock merging operations
MERGED_STOCK_ITEMS = 45, _('Merged stock items')
MERGED_STOCK_ITEMS = 45
# Convert stock item to variant
CONVERTED_TO_VARIANT = 48, _('Converted to variant')
CONVERTED_TO_VARIANT = 48
# Build order codes
BUILD_OUTPUT_CREATED = 50, _('Build order output created')
BUILD_OUTPUT_COMPLETED = 55, _('Build order output completed')
BUILD_OUTPUT_REJECTED = 56, _('Build order output rejected')
BUILD_CONSUMED = 57, _('Consumed by build order')
BUILD_OUTPUT_CREATED = 50
BUILD_OUTPUT_COMPLETED = 55
BUILD_CONSUMED = 57
# Sales order codes
SHIPPED_AGAINST_SALES_ORDER = 60, _("Shipped against Sales Order")
SHIPPED_AGAINST_SALES_ORDER = 60
# Purchase order codes
RECEIVED_AGAINST_PURCHASE_ORDER = 70, _('Received against Purchase Order')
RECEIVED_AGAINST_PURCHASE_ORDER = 70
# Return order codes
RETURNED_AGAINST_RETURN_ORDER = 80, _('Returned against Return Order')
RETURNED_AGAINST_RETURN_ORDER = 80
# Customer actions
SENT_TO_CUSTOMER = 100, _('Sent to customer')
RETURNED_FROM_CUSTOMER = 105, _('Returned from customer')
SENT_TO_CUSTOMER = 100
RETURNED_FROM_CUSTOMER = 105
options = {
LEGACY: _('Legacy stock tracking entry'),
CREATED: _('Stock item created'),
EDITED: _('Edited stock item'),
ASSIGNED_SERIAL: _('Assigned serial number'),
STOCK_COUNT: _('Stock counted'),
STOCK_ADD: _('Stock manually added'),
STOCK_REMOVE: _('Stock manually removed'),
STOCK_MOVE: _('Location changed'),
INSTALLED_INTO_ASSEMBLY: _('Installed into assembly'),
REMOVED_FROM_ASSEMBLY: _('Removed from assembly'),
INSTALLED_CHILD_ITEM: _('Installed component item'),
REMOVED_CHILD_ITEM: _('Removed component item'),
SPLIT_FROM_PARENT: _('Split from parent item'),
SPLIT_CHILD_ITEM: _('Split child item'),
MERGED_STOCK_ITEMS: _('Merged stock items'),
CONVERTED_TO_VARIANT: _('Converted to variant'),
SENT_TO_CUSTOMER: _('Sent to customer'),
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
BUILD_OUTPUT_CREATED: _('Build order output created'),
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
BUILD_CONSUMED: _('Consumed by build order'),
SHIPPED_AGAINST_SALES_ORDER: _("Shipped against Sales Order"),
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against Purchase Order'),
RETURNED_AGAINST_RETURN_ORDER: _('Returned against Return Order'),
}
class BuildStatus(StatusCode):
"""Build status codes."""
PENDING = 10, _("Pending"), 'secondary' # Build is pending / active
PRODUCTION = 20, _("Production"), 'primary' # BuildOrder is in production
CANCELLED = 30, _("Cancelled"), 'danger' # Build was cancelled
COMPLETE = 40, _("Complete"), 'success' # Build is complete
PENDING = 10 # Build is pending / active
PRODUCTION = 20 # BuildOrder is in production
CANCELLED = 30 # Build was cancelled
COMPLETE = 40 # Build is complete
options = {
PENDING: _("Pending"),
PRODUCTION: _("Production"),
CANCELLED: _("Cancelled"),
COMPLETE: _("Complete"),
}
class BuildStatusGroups:
"""Groups for BuildStatus codes."""
colors = {
PENDING: 'secondary',
PRODUCTION: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
}
ACTIVE_CODES = [
BuildStatus.PENDING.value,
BuildStatus.PRODUCTION.value,
PENDING,
PRODUCTION,
]
@@ -164,40 +377,68 @@ class ReturnOrderStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrder"""
# Order is pending, waiting for receipt of items
PENDING = 10, _("Pending"), 'secondary'
PENDING = 10
# Items have been received, and are being inspected
IN_PROGRESS = 20, _("In Progress"), 'primary'
IN_PROGRESS = 20
COMPLETE = 30, _("Complete"), 'success'
CANCELLED = 40, _("Cancelled"), 'danger'
class ReturnOrderStatusGroups:
"""Groups for ReturnOrderStatus codes."""
COMPLETE = 30
CANCELLED = 40
OPEN = [
ReturnOrderStatus.PENDING.value,
ReturnOrderStatus.IN_PROGRESS.value,
PENDING,
IN_PROGRESS,
]
options = {
PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
COMPLETE: _("Complete"),
CANCELLED: _("Cancelled"),
}
colors = {
PENDING: 'secondary',
IN_PROGRESS: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
}
class ReturnOrderLineStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrderLineItem"""
PENDING = 10, _("Pending"), 'secondary'
PENDING = 10
# Item is to be returned to customer, no other action
RETURN = 20, _("Return"), 'success'
RETURN = 20
# Item is to be repaired, and returned to customer
REPAIR = 30, _("Repair"), 'primary'
REPAIR = 30
# Item is to be replaced (new item shipped)
REPLACE = 40, _("Replace"), 'warning'
REPLACE = 40
# Item is to be refunded (cannot be repaired)
REFUND = 50, _("Refund"), 'info'
REFUND = 50
# Item is rejected
REJECT = 60, _("Reject"), 'danger'
REJECT = 60
options = {
PENDING: _('Pending'),
RETURN: _('Return'),
REPAIR: _('Repair'),
REFUND: _('Refund'),
REPLACE: _('Replace'),
REJECT: _('Reject')
}
colors = {
PENDING: 'secondary',
RETURN: 'success',
REPAIR: 'primary',
REFUND: 'info',
REPLACE: 'warning',
REJECT: 'danger',
}

View File

@@ -12,6 +12,7 @@ from datetime import datetime, timedelta
from typing import Callable, List
from django.conf import settings
from django.core import mail as django_mail
from django.core.exceptions import AppRegistryNotReady
from django.core.management import call_command
from django.db import DEFAULT_DB_ALIAS, connections
@@ -70,7 +71,7 @@ def raise_warning(msg):
# If testing is running raise a warning that can be asserted
if settings.TESTING:
warnings.warn(msg, stacklevel=2)
warnings.warn(msg)
def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
@@ -91,15 +92,13 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
"""
from common.models import InvenTreeSetting
from InvenTree.ready import isInTestMode
if n_days <= 0:
logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run")
return False
# Sleep a random number of seconds to prevent worker conflict
if not isInTestMode():
time.sleep(random.randint(1, 5))
time.sleep(random.randint(1, 5))
attempt_key = f'_{task_name}_ATTEMPT'
success_key = f'_{task_name}_SUCCESS'
@@ -168,7 +167,6 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
If workers are not running or force_sync flag
is set then the task is ran synchronously.
"""
try:
import importlib
@@ -188,8 +186,6 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
task.run()
except ImportError:
raise_warning(f"WARNING: '{taskname}' not started - Function not found")
except Exception as exc:
raise_warning(f"WARNING: '{taskname}' not started due to {type(exc)}")
else:
if callable(taskname):
@@ -253,7 +249,7 @@ class ScheduledTask:
class TaskRegister:
"""Registry for periodicall tasks."""
"""Registery for periodicall tasks."""
task_list: List[ScheduledTask] = []
def register(self, task, schedule, minutes: int = None):
@@ -499,7 +495,7 @@ def check_for_updates():
def update_exchange_rates():
"""Update currency exchange rates."""
try:
from djmoney.contrib.exchange.models import Rate
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from common.settings import currency_code_default, currency_codes
from InvenTree.exchange import InvenTreeExchange
@@ -511,9 +507,22 @@ def update_exchange_rates():
# Other error?
return
# Test to see if the database is ready yet
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
except ExchangeBackend.DoesNotExist:
pass
except Exception: # pragma: no cover
# Some other error
logger.warning("update_exchange_rates: Database not ready")
return
backend = InvenTreeExchange()
logger.info(f"Updating exchange rates from {backend.url}")
base = currency_code_default()
logger.info(f"Updating exchange rates using base currency '{base}'")
logger.info(f"Using base currency '{base}'")
try:
backend.update_rates(base_currency=base)
@@ -521,7 +530,7 @@ def update_exchange_rates():
# Remove any exchange rates which are not in the provided currencies
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
except Exception as e: # pragma: no cover
logger.error(f"Error updating exchange rates: {e} ({type(e)})")
logger.error(f"Error updating exchange rates: {e}")
@scheduled_task(ScheduledTask.DAILY)
@@ -549,11 +558,27 @@ def run_backup():
record_task_success('run_backup')
def send_email(subject, body, recipients, from_email=None, html_message=None):
"""Send an email with the specified subject and body, to the specified recipients list."""
if type(recipients) == str:
recipients = [recipients]
offload_task(
django_mail.send_mail,
subject,
body,
from_email,
recipients,
fail_silently=False,
html_message=html_message
)
@scheduled_task(ScheduledTask.DAILY)
def check_for_migrations(worker: bool = True):
"""Checks if migrations are needed.
If the setting auto_update is enabled we will start updating.
If the setting auto_update is enabled we will start updateing.
"""
# Test if auto-updates are enabled
if not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):

View File

@@ -1,36 +0,0 @@
"""Custom template loader for InvenTree"""
import os
from django.conf import settings
from django.template.loaders.base import Loader as BaseLoader
from django.template.loaders.cached import Loader as CachedLoader
class InvenTreeTemplateLoader(CachedLoader):
"""Custom template loader which bypasses cache for PDF export"""
def get_template(self, template_name, skip=None):
"""Return a template object for the given template name.
Any custom report or label templates will be forced to reload (without cache).
This ensures that generated PDF reports / labels are always up-to-date.
"""
# List of template patterns to skip cache for
skip_cache_dirs = [
os.path.abspath(os.path.join(settings.MEDIA_ROOT, 'report')),
os.path.abspath(os.path.join(settings.MEDIA_ROOT, 'label')),
'snippets/',
]
# Initially load the template using the cached loader
template = CachedLoader.get_template(self, template_name, skip)
template_path = str(template.name)
# If the template matches any of the skip patterns, reload it without cache
if any(template_path.startswith(d) for d in skip_cache_dirs):
template = BaseLoader.get_template(self, template_name, skip)
return template

View File

@@ -6,7 +6,8 @@ from django.urls import reverse
from rest_framework import status
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.helpers import InvenTreeTestCase
from users.models import RuleSet, update_group_roles

View File

@@ -7,7 +7,7 @@ from django.urls import reverse
from error_report.models import Error
from InvenTree.exceptions import log_error
from InvenTree.unit_test import InvenTreeTestCase
from InvenTree.helpers import InvenTreeTestCase
class MiddlewareTests(InvenTreeTestCase):
@@ -28,13 +28,13 @@ class MiddlewareTests(InvenTreeTestCase):
self.client.logout()
# check that static files go through
# TODO @matmair re-enable this check
# TODO @matmair reenable this check
# self.check_path('/static/css/inventree.css', 302)
# check that account things go through
self.check_path(reverse('account_login'))
# logout goes directly to login
# logout goes diretly to login
self.check_path(reverse('account_logout'))
# check that frontend code is redirected to login

View File

@@ -70,11 +70,11 @@ class InvenTreeTaskTests(TestCase):
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree' not started - Malformed function path"):
InvenTree.tasks.offload_task('InvenTree')
# Non existent app
# Non exsistent app
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTreeABC.test_tasks.doesnotmatter' not started - No module named 'InvenTreeABC.test_tasks'"):
InvenTree.tasks.offload_task('InvenTreeABC.test_tasks.doesnotmatter')
# Non existent function
# Non exsistent function
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree.test_tasks.doesnotexsist' not started - No function named 'doesnotexsist'"):
InvenTree.tasks.offload_task('InvenTree.test_tasks.doesnotexsist')

View File

@@ -14,18 +14,18 @@ class URLTest(TestCase):
# Need fixture data in the database
fixtures = [
'settings',
'build',
'company',
'manufacturer_part',
'price_breaks',
'supplier_part',
'order',
'sales_order',
'bom',
'category',
'params',
'part_pricebreaks',
'part',
'bom',
'build',
'test_templates',
'location',
'stock_tests',

View File

@@ -5,7 +5,7 @@ import os
from django.contrib.auth import get_user_model
from django.urls import reverse
from InvenTree.unit_test import InvenTreeTestCase
from InvenTree.helpers import InvenTreeTestCase
class ViewTests(InvenTreeTestCase):

View File

@@ -14,19 +14,17 @@ from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
import requests
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money
import InvenTree.conversion
import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.tasks
from common.models import InvenTreeSetting
from common.settings import currency_codes
from InvenTree.sanitizer import sanitize_svg
from InvenTree.unit_test import InvenTreeTestCase
from part.models import Part, PartCategory
from stock.models import StockItem, StockLocation
@@ -35,45 +33,6 @@ from .tasks import offload_task
from .validators import validate_overage
class ConversionTest(TestCase):
"""Tests for conversion of physical units"""
def test_dimensionless_units(self):
"""Tests for 'dimensonless' unit quantities"""
# Test some dimensionless units
tests = {
'ea': 1,
'each': 1,
'3 piece': 3,
'5 dozen': 60,
'3 hundred': 300,
'2 thousand': 2000,
'12 pieces': 12,
}
for val, expected in tests.items():
q = InvenTree.conversion.convert_physical_value(val).to_base_units()
self.assertEqual(q.magnitude, expected)
def test_invalid_values(self):
"""Test conversion of invalid inputs"""
inputs = [
'-',
';;',
'-x',
'?',
'--',
'+',
'++',
]
for val in inputs:
with self.assertRaises(ValidationError):
InvenTree.conversion.convert_physical_value(val)
class ValidatorTest(TestCase):
"""Simple tests for custom field validators."""
@@ -233,34 +192,6 @@ class FormatTest(TestCase):
class TestHelpers(TestCase):
"""Tests for InvenTree helper functions."""
def test_absolute_url(self):
"""Test helper function for generating an absolute URL"""
base = "https://demo.inventree.org:12345"
InvenTreeSetting.set_setting('INVENTREE_BASE_URL', base, change_user=None)
tests = {
"": base,
"api/": base + "/api/",
"/api/": base + "/api/",
"api": base + "/api",
"media/label/output/": base + "/media/label/output/",
"static/logo.png": base + "/static/logo.png",
"https://www.google.com": "https://www.google.com",
"https://demo.inventree.org:12345/out.html": "https://demo.inventree.org:12345/out.html",
"https://demo.inventree.org/test.html": "https://demo.inventree.org/test.html",
"http://www.cwi.nl:80/%7Eguido/Python.html": "http://www.cwi.nl:80/%7Eguido/Python.html",
"test.org": base + "/test.org",
}
for url, expected in tests.items():
# Test with supplied base URL
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url, site_url=base), expected)
# Test without supplied base URL
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url), expected)
def test_image_url(self):
"""Test if a filename looks like an image."""
for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']:
@@ -328,12 +259,12 @@ class TestHelpers(TestCase):
"\\invalid-url"
]:
with self.assertRaises(django_exceptions.ValidationError):
InvenTree.helpers_model.download_image_from_url(url)
helpers.download_image_from_url(url)
def dl_helper(url, expected_error, timeout=2.5, retries=3):
"""Helper function for unit testing downloads.
As the httpstat.us service occasionally refuses a connection,
As the httpstat.us service occassionaly refuses a connection,
we will simply try multiple times
"""
@@ -343,7 +274,7 @@ class TestHelpers(TestCase):
while tries < retries:
try:
InvenTree.helpers_model.download_image_from_url(url, timeout=timeout)
helpers.download_image_from_url(url, timeout=timeout)
break
except Exception as exc:
if type(exc) is expected_error:
@@ -356,12 +287,10 @@ class TestHelpers(TestCase):
time.sleep(10 * tries)
# Attempt to download an image which throws a 404
# TODO: Re-implement this test when we are happier with the external service
# dl_helper("https://httpstat.us/404", requests.exceptions.HTTPError, timeout=10)
dl_helper("https://httpstat.us/404", requests.exceptions.HTTPError, timeout=10)
# Attempt to download, but timeout
# TODO: Re-implement this test when we are happier with the external service
# dl_helper("https://httpstat.us/200?sleep=5000", requests.exceptions.ReadTimeout, timeout=1)
dl_helper("https://httpstat.us/200?sleep=5000", requests.exceptions.ReadTimeout, timeout=1)
large_img = "https://github.com/inventree/InvenTree/raw/master/InvenTree/InvenTree/static/img/paper_splash_large.jpg"
@@ -369,27 +298,13 @@ class TestHelpers(TestCase):
# Attempt to download an image which is too large
with self.assertRaises(ValueError):
InvenTree.helpers_model.download_image_from_url(large_img, timeout=10)
helpers.download_image_from_url(large_img, timeout=10)
# Increase allowable download size
InvenTreeSetting.set_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 5, change_user=None)
# Download a valid image (should not throw an error)
InvenTree.helpers_model.download_image_from_url(large_img, timeout=10)
def test_model_mixin(self):
"""Test the getModelsWithMixin function"""
from InvenTree.models import InvenTreeBarcodeMixin
models = InvenTree.helpers_model.getModelsWithMixin(InvenTreeBarcodeMixin)
self.assertIn(Part, models)
self.assertIn(StockLocation, models)
self.assertIn(StockItem, models)
self.assertNotIn(PartCategory, models)
self.assertNotIn(InvenTreeSetting, models)
helpers.download_image_from_url(large_img, timeout=10)
class TestQuoteWrap(TestCase):
@@ -587,7 +502,7 @@ class TestSerialNumberExtraction(TestCase):
self.assertEqual(sn, ['5', '6', '7', '8'])
def test_failures(self):
"""Test wrong serial numbers."""
"""Test wron serial numbers."""
e = helpers.extract_serial_numbers
# Test duplicates
@@ -745,7 +660,6 @@ class CurrencyTests(TestCase):
else: # pragma: no cover
print("Exchange rate update failed - retrying")
print(f'Expected {currency_codes()}, got {[a.currency for a in rates]}')
time.sleep(1)
self.assertTrue(update_successful)
@@ -782,7 +696,7 @@ class TestStatus(TestCase):
self.assertEqual(ready.isImportingData(), False)
class TestSettings(InvenTreeTestCase):
class TestSettings(helpers.InvenTreeTestCase):
"""Unit tests for settings."""
superuser = True
@@ -878,7 +792,7 @@ class TestSettings(InvenTreeTestCase):
'inventree/data/config.yaml',
]
self.assertTrue(any(opt in str(config.get_config_file()).lower() for opt in valid))
self.assertTrue(any([opt in str(config.get_config_file()).lower() for opt in valid]))
# with env set
with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}):
@@ -893,7 +807,7 @@ class TestSettings(InvenTreeTestCase):
'inventree/data/plugins.txt',
]
self.assertTrue(any(opt in str(config.get_plugin_file()).lower() for opt in valid))
self.assertTrue(any([opt in str(config.get_plugin_file()).lower() for opt in valid]))
# with env set
with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}):
@@ -921,7 +835,7 @@ class TestSettings(InvenTreeTestCase):
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, typecast=dict), {})
class TestInstanceName(InvenTreeTestCase):
class TestInstanceName(helpers.InvenTreeTestCase):
"""Unit tests for instance name."""
def test_instance_name(self):
@@ -949,7 +863,7 @@ class TestInstanceName(InvenTreeTestCase):
self.assertEqual(site_obj.domain, 'http://127.1.2.3')
class TestOffloadTask(InvenTreeTestCase):
class TestOffloadTask(helpers.InvenTreeTestCase):
"""Tests for offloading tasks to the background worker"""
fixtures = [
@@ -1046,7 +960,7 @@ class TestOffloadTask(InvenTreeTestCase):
self.assertTrue(result)
class BarcodeMixinTest(InvenTreeTestCase):
class BarcodeMixinTest(helpers.InvenTreeTestCase):
"""Tests for the InvenTreeBarcodeMixin mixin class"""
def test_barcode_model_type(self):
@@ -1079,7 +993,7 @@ class SanitizerTest(TestCase):
"""Simple tests for sanitizer functions."""
def test_svg_sanitizer(self):
"""Test that SVGs are sanitized accordingly."""
"""Test that SVGs are sanitized acordingly."""
valid_string = """<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="svg2" height="400" width="400">{0}
<path id="path1" d="m -151.78571,359.62883 v 112.76373 l 97.068507,-56.04253 V 303.14815 Z" style="fill:#ddbc91;"></path>
</svg>"""

View File

@@ -9,10 +9,7 @@ from django.contrib import admin
from django.urls import include, path, re_path
from django.views.generic.base import RedirectView
from dj_rest_auth.registration.views import (ConfirmEmailView,
SocialAccountDisconnectView,
SocialAccountListView)
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from rest_framework.documentation import include_docs_urls
from build.api import build_api_urls
from build.urls import build_urls
@@ -34,7 +31,6 @@ from stock.urls import stock_urls
from users.api import user_urls
from .api import APISearchView, InfoView, NotFoundView
from .social_auth_urls import SocialProvierListView, social_auth_urlpatterns
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
CustomEmailView, CustomLoginView,
CustomPasswordResetFromKeyView,
@@ -66,26 +62,12 @@ apipatterns = [
# Plugin endpoints
path('', include(plugin_api_urls)),
# Common endpoints endpoint
# Webhook endpoints
path('', include(common_api_urls)),
# OpenAPI Schema
re_path('schema/', SpectacularAPIView.as_view(custom_settings={'SCHEMA_PATH_PREFIX': '/api/'}), name='schema'),
# InvenTree information endpoint
path('', InfoView.as_view(), name='api-inventree-info'),
# Auth API endpoints
path('auth/', include([
re_path(r'^registration/account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailView.as_view(), name='account_confirm_email'),
path('registration/', include('dj_rest_auth.registration.urls')),
path('providers/', SocialProvierListView.as_view(), name='social_providers'),
path('social/', include(social_auth_urlpatterns)),
path('social/', SocialAccountListView.as_view(), name='social_account_list'),
path('social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
path('', include('dj_rest_auth.urls')),
])),
# Unknown endpoint
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
]
@@ -110,11 +92,10 @@ notifications_urls = [
dynamic_javascript_urls = [
re_path(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'),
re_path(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'),
re_path(r'^permissions.js', DynamicJsView.as_view(template_name='js/dynamic/permissions.js'), name='permissions.js'),
re_path(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'),
]
# These javascript files are passed through the Django translation layer
# These javascript files are pased through the Django translation layer
translated_javascript_urls = [
re_path(r'^api.js', DynamicJsView.as_view(template_name='js/translated/api.js'), name='api.js'),
re_path(r'^attachment.js', DynamicJsView.as_view(template_name='js/translated/attachment.js'), name='attachment.js'),
@@ -126,7 +107,6 @@ translated_javascript_urls = [
re_path(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'),
re_path(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
re_path(r'^helpers.js', DynamicJsView.as_view(template_name='js/translated/helpers.js'), name='helpers.js'),
re_path(r'^index.js', DynamicJsView.as_view(template_name='js/translated/index.js'), name='index.js'),
re_path(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'),
re_path(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'),
re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
@@ -156,7 +136,7 @@ backendpatterns = [
re_path(r'^auth/?', auth_request),
re_path(r'^api/', include(apipatterns)),
re_path(r'^api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
re_path(r'^api-doc/', include_docs_urls(title='InvenTree API')),
]
frontendpatterns = [

View File

@@ -8,31 +8,9 @@ from django.core import validators
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _
import pint
from jinja2 import Template
from moneyed import CURRENCIES
import InvenTree.conversion
def validate_physical_units(unit):
"""Ensure that a given unit is a valid physical unit."""
unit = unit.strip()
# Ignore blank units
if not unit:
return
ureg = InvenTree.conversion.get_unit_registry()
try:
ureg(unit)
except AttributeError:
raise ValidationError(_('Invalid physical unit'))
except pint.errors.UndefinedUnitError:
raise ValidationError(_('Invalid physical unit'))
def validate_currency_code(code):
"""Check that a given code is a valid currency code."""

View File

@@ -4,41 +4,25 @@ Provides information on the current InvenTree version
"""
import os
import pathlib
import platform
import re
from datetime import datetime as dt
from datetime import timedelta as td
import subprocess
import django
from django.conf import settings
from dulwich.repo import NotGitRepository, Repo
from .api_version import INVENTREE_API_VERSION
import common.models
from InvenTree.api_version import INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = "0.12.10"
# Discover git
try:
main_repo = Repo(pathlib.Path(__file__).parent.parent.parent)
main_commit = main_repo[main_repo.head()]
except (NotGitRepository, FileNotFoundError):
main_commit = None
INVENTREE_SW_VERSION = "0.11.3"
def inventreeInstanceName():
"""Returns the InstanceName settings for the current database."""
import common.models
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
def inventreeInstanceTitle():
"""Returns the InstanceTitle for the current database."""
import common.models
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
else:
@@ -82,7 +66,6 @@ def isInvenTreeUpToDate():
A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION"
"""
import common.models
latest = common.models.InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION', backup_value=None, create=False)
# No record for "latest" version - we must assume we are up to date!
@@ -114,9 +97,10 @@ def inventreeCommitHash():
if commit_hash:
return commit_hash
if main_commit is None:
try:
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
except Exception: # pragma: no cover
return None
return main_commit.sha().hexdigest()[0:7]
def inventreeCommitDate():
@@ -127,56 +111,8 @@ def inventreeCommitDate():
if commit_date:
return commit_date.split(' ')[0]
if main_commit is None:
return None
commit_dt = dt.fromtimestamp(main_commit.commit_time) + td(seconds=main_commit.commit_timezone)
return str(commit_dt.date())
def inventreeInstaller():
"""Returns the installer for the running codebase - if set."""
# First look in the environment variables, e.g. if running in docker
installer = os.environ.get('INVENTREE_PKG_INSTALLER', '')
if installer:
return installer
elif settings.DOCKER:
return 'DOC'
elif main_commit is not None:
return 'GIT'
return None
def inventreeBranch():
"""Returns the branch for the running codebase - if set."""
# First look in the environment variables, e.g. if running in docker
branch = os.environ.get('INVENTREE_PKG_BRANCH', '')
if branch:
return branch
if main_commit is None:
return None
try:
branch = main_repo.refs.follow(b'HEAD')[0][1].decode()
return branch.removeprefix('refs/heads/')
except IndexError:
return None # pragma: no cover
def inventreeTarget():
"""Returns the target platform for the running codebase - if set."""
# First look in the environment variables, e.g. if running in docker
return os.environ.get('INVENTREE_PKG_TARGET', None)
def inventreePlatform():
"""Returns the platform for the instance."""
return platform.platform(aliased=True)
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
return d.split(' ')[0]
except Exception: # pragma: no cover
return None

View File

@@ -31,8 +31,8 @@ from allauth_2fa.views import TwoFactorRemove
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
import common.models as common_models
import common.settings as common_settings
from common.models import ColorTheme, InvenTreeSetting
from common.settings import currency_code_default, currency_codes
from part.models import PartCategory
from users.models import RuleSet, check_user_role
@@ -357,7 +357,7 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
- Updates model with POST field data
- Performs form and object validation
- If errors exist, re-render the form
- Otherwise, return success status
- Otherwise, return sucess status
"""
self.request = request
@@ -386,7 +386,7 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
if valid:
# Save the updated object to the database
# Save the updated objec to the database
self.save(self.object, form)
self.object = self.get_object()
@@ -447,7 +447,8 @@ class SetPasswordView(AjaxUpdateView):
if valid:
# Old password must be correct
if user.has_usable_password() and not user.check_password(old_password):
if not user.check_password(old_password):
form.add_error('old_password', _('Wrong password provided'))
valid = False
@@ -513,10 +514,10 @@ class SettingsView(TemplateView):
"""Add data for template."""
ctx = super().get_context_data(**kwargs).copy()
ctx['settings'] = common_models.InvenTreeSetting.objects.all().order_by('key')
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
ctx["base_currency"] = common_settings.currency_code_default()
ctx["currencies"] = common_settings.currency_codes
ctx["base_currency"] = currency_code_default()
ctx["currencies"] = currency_codes
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
@@ -524,10 +525,8 @@ class SettingsView(TemplateView):
# When were the rates last updated?
try:
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
if backend.exists():
backend = backend.first()
ctx["rates_updated"] = backend.last_update
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
ctx["rates_updated"] = backend.last_update
except Exception:
ctx["rates_updated"] = None
@@ -621,8 +620,8 @@ class AppearanceSelectView(RedirectView):
def get_user_theme(self):
"""Get current user color theme."""
try:
user_theme = common_models.ColorTheme.objects.filter(user=self.request.user).get()
except common_models.ColorTheme.DoesNotExist:
user_theme = ColorTheme.objects.filter(user=self.request.user).get()
except ColorTheme.DoesNotExist:
user_theme = None
return user_theme
@@ -636,15 +635,11 @@ class AppearanceSelectView(RedirectView):
# Create theme entry if user did not select one yet
if not user_theme:
user_theme = common_models.ColorTheme()
user_theme = ColorTheme()
user_theme.user = request.user
if theme:
try:
user_theme.name = theme
user_theme.save()
except Exception:
pass
user_theme.name = theme
user_theme.save()
return redirect(reverse_lazy('settings'))

View File

@@ -4,9 +4,9 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export import widgets
import import_export.widgets as widgets
from build.models import Build, BuildLine, BuildItem
from build.models import Build, BuildItem
from InvenTree.admin import InvenTreeResource
import part.models
@@ -87,33 +87,18 @@ class BuildItemAdmin(admin.ModelAdmin):
"""Class for managing the BuildItem model via the admin interface"""
list_display = (
'build',
'stock_item',
'quantity'
)
autocomplete_fields = [
'build_line',
'build',
'bom_item',
'stock_item',
'install_into',
]
class BuildLineAdmin(admin.ModelAdmin):
"""Class for managing the BuildLine model via the admin interface"""
list_display = (
'build',
'bom_item',
'quantity',
)
search_fields = [
'build__title',
'build__reference',
'bom_item__sub_part__name',
]
admin.site.register(Build, BuildAdmin)
admin.site.register(BuildItem, BuildItemAdmin)
admin.site.register(BuildLine, BuildLineAdmin)

View File

@@ -1,6 +1,5 @@
"""JSON API for the Build app."""
from django.db.models import F
from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
@@ -10,16 +9,14 @@ from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
from generic.states import StatusView
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView, StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.status_codes import BuildStatus, BuildStatusGroups
from InvenTree.status_codes import BuildStatus
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import common.models
import build.admin
import build.serializers
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
from build.models import Build, BuildItem, BuildOrderAttachment
import part.models
from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
@@ -44,9 +41,9 @@ class BuildFilter(rest_filters.FilterSet):
def filter_active(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are active."""
if str2bool(value):
return queryset.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
return queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
else:
return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES)
return queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
@@ -90,21 +87,6 @@ class BuildFilter(rest_filters.FilterSet):
lookup_expr="iexact"
)
project_code = rest_filters.ModelChoiceFilter(
queryset=common.models.ProjectCode.objects.all(),
field_name='project_code'
)
has_project_code = rest_filters.BooleanFilter(label='has_project_code', method='filter_has_project_code')
def filter_has_project_code(self, queryset, name, value):
"""Filter by whether or not the order has a project code"""
if str2bool(value):
return queryset.exclude(project_code=None)
else:
return queryset.filter(project_code=None)
class BuildList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of Build objects.
@@ -130,13 +112,11 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
'completed',
'issued_by',
'responsible',
'project_code',
'priority',
]
ordering_field_aliases = {
'reference': ['reference_int', 'reference'],
'project_code': ['project_code__code'],
}
ordering = '-reference'
@@ -147,7 +127,6 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
'part__name',
'part__IPN',
'part__description',
'project_code__code',
'priority',
]
@@ -271,88 +250,6 @@ class BuildUnallocate(CreateAPI):
return ctx
class BuildLineFilter(rest_filters.FilterSet):
"""Custom filterset for the BuildLine API endpoint."""
class Meta:
"""Meta information for the BuildLineFilter class."""
model = BuildLine
fields = [
'build',
'bom_item',
]
# Fields on related models
consumable = rest_filters.BooleanFilter(label=_('Consumable'), field_name='bom_item__consumable')
optional = rest_filters.BooleanFilter(label=_('Optional'), field_name='bom_item__optional')
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
def filter_allocated(self, queryset, name, value):
"""Filter by whether each BuildLine is fully allocated"""
if str2bool(value):
return queryset.filter(allocated__gte=F('quantity'))
else:
return queryset.filter(allocated__lt=F('quantity'))
class BuildLineEndpoint:
"""Mixin class for BuildLine API endpoints."""
queryset = BuildLine.objects.all()
serializer_class = build.serializers.BuildLineSerializer
def get_queryset(self):
"""Override queryset to select-related and annotate"""
queryset = super().get_queryset()
queryset = queryset.select_related(
'build', 'bom_item',
)
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset)
return queryset
class BuildLineList(BuildLineEndpoint, ListCreateAPI):
"""API endpoint for accessing a list of BuildLine objects"""
filterset_class = BuildLineFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = [
'part',
'allocated',
'reference',
'quantity',
'consumable',
'optional',
'unit_quantity',
'available_stock',
]
ordering_field_aliases = {
'part': 'bom_item__sub_part__name',
'reference': 'bom_item__reference',
'unit_quantity': 'bom_item__quantity',
'consumable': 'bom_item__consumable',
'optional': 'bom_item__optional',
}
search_fields = [
'bom_item__sub_part__name',
'bom_item__reference',
]
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a BuildLine object."""
pass
class BuildOrderContextMixin:
"""Mixin class which adds build order as serializer context variable."""
@@ -379,19 +276,6 @@ class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
serializer_class = build.serializers.BuildOutputCreateSerializer
class BuildOutputScrap(BuildOrderContextMixin, CreateAPI):
"""API endpoint for scrapping build output(s)."""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildOutputScrapSerializer
def get_serializer_context(self):
"""Add extra context information to the endpoint serializer."""
ctx = super().get_serializer_context()
ctx['to_complete'] = False
return ctx
class BuildOutputComplete(BuildOrderContextMixin, CreateAPI):
"""API endpoint for completing build outputs."""
@@ -475,8 +359,9 @@ class BuildItemFilter(rest_filters.FilterSet):
"""Metaclass option"""
model = BuildItem
fields = [
'build_line',
'build',
'stock_item',
'bom_item',
'install_into',
]
@@ -485,11 +370,6 @@ class BuildItemFilter(rest_filters.FilterSet):
field_name='stock_item__part',
)
build = rest_filters.ModelChoiceFilter(
queryset=build.models.Build.objects.all(),
field_name='build_line__build',
)
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
def filter_tracked(self, queryset, name, value):
@@ -515,9 +395,10 @@ class BuildItemList(ListCreateAPI):
try:
params = self.request.query_params
for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']:
if key in params:
kwargs[key] = str2bool(params.get(key, False))
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['build_detail'] = str2bool(params.get('build_detail', False))
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
kwargs['stock_detail'] = str2bool(params.get('stock_detail', True))
except AttributeError:
pass
@@ -528,8 +409,9 @@ class BuildItemList(ListCreateAPI):
queryset = BuildItem.objects.all()
queryset = queryset.select_related(
'build_line',
'build_line__build',
'bom_item',
'bom_item__sub_part',
'build',
'install_into',
'stock_item',
'stock_item__location',
@@ -539,7 +421,7 @@ class BuildItemList(ListCreateAPI):
return queryset
def filter_queryset(self, queryset):
"""Custom query filtering for the BuildItem list."""
"""Customm query filtering for the BuildItem list."""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
@@ -591,12 +473,6 @@ build_api_urls = [
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])),
# Build lines
re_path(r'^line/', include([
path(r'<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
re_path(r'^.*$', BuildLineList.as_view(), name='api-build-line-list'),
])),
# Build Items
re_path(r'^item/', include([
path(r'<int:pk>/', include([
@@ -613,7 +489,6 @@ build_api_urls = [
re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
re_path(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
re_path(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
re_path(r'^scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'),
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),

View File

@@ -11,6 +11,10 @@ def update_tree(apps, schema_editor):
Build.objects.rebuild()
def nupdate_tree(apps, schema_editor): # pragma: no cover
pass
class Migration(migrations.Migration):
atomic = False
@@ -49,5 +53,5 @@ class Migration(migrations.Migration):
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop),
migrations.RunPython(update_tree, reverse_code=nupdate_tree),
]

View File

@@ -23,6 +23,13 @@ def add_default_reference(apps, schema_editor):
print(f"\nUpdated build reference for {count} existing BuildOrder objects")
def reverse_default_reference(apps, schema_editor): # pragma: no cover
"""
Do nothing! But we need to have a function here so the whole process is reversible.
"""
pass
class Migration(migrations.Migration):
atomic = False
@@ -42,7 +49,7 @@ class Migration(migrations.Migration):
# Auto-populate the new reference field for any existing build order objects
migrations.RunPython(
add_default_reference,
reverse_code=migrations.RunPython.noop
reverse_code=reverse_default_reference
),
# Now that each build has a non-empty, unique reference, update the field requirements!

View File

@@ -51,6 +51,14 @@ def assign_bom_items(apps, schema_editor):
logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries")
def unassign_bom_items(apps, schema_editor): # pragma: no cover
"""
Reverse migration does not do anything.
Function here to preserve ability to reverse migration
"""
pass
class Migration(migrations.Migration):
dependencies = [
@@ -58,5 +66,5 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunPython(assign_bom_items, reverse_code=migrations.RunPython.noop),
migrations.RunPython(assign_bom_items, reverse_code=unassign_bom_items),
]

View File

@@ -31,6 +31,12 @@ def build_refs(apps, schema_editor):
build.reference_int = ref
build.save()
def unbuild_refs(apps, schema_editor): # pragma: no cover
"""
Provided only for reverse migration compatibility
"""
pass
class Migration(migrations.Migration):
@@ -43,6 +49,6 @@ class Migration(migrations.Migration):
operations = [
migrations.RunPython(
build_refs,
reverse_code=migrations.RunPython.noop
reverse_code=unbuild_refs
)
]

View File

@@ -50,6 +50,11 @@ def update_build_reference(apps, schema_editor):
print(f"Updated reference field for {n} BuildOrder objects")
def nupdate_build_reference(apps, schema_editor):
"""Reverse migration code. Does nothing."""
pass
class Migration(migrations.Migration):
dependencies = [
@@ -59,6 +64,6 @@ class Migration(migrations.Migration):
operations = [
migrations.RunPython(
update_build_reference,
reverse_code=migrations.RunPython.noop,
reverse_code=nupdate_build_reference,
)
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 3.2.18 on 2023-04-19 00:37
import InvenTree.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0041_alter_build_title'),
]
operations = [
migrations.AlterField(
model_name='build',
name='notes',
field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 3.2.19 on 2023-05-19 06:04
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0109_auto_20230517_1048'),
('build', '0042_alter_build_notes'),
]
operations = [
migrations.CreateModel(
name='BuildLine',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=5, default=1, help_text='Required quantity for build order', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
('bom_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='build_lines', to='part.bomitem')),
('build', models.ForeignKey(help_text='Build object', on_delete=django.db.models.deletion.CASCADE, related_name='build_lines', to='build.build')),
],
options={
'unique_together': {('build', 'bom_item')},
},
),
]

View File

@@ -1,97 +0,0 @@
# Generated by Django 3.2.19 on 2023-05-28 14:10
from django.db import migrations
def get_bom_items_for_part(part, Part, BomItem):
""" Return a list of all BOM items for a given part.
Note that we cannot use the ORM here (as we are inside a data migration),
so we *copy* the logic from the Part class.
This is a snapshot of the Part.get_bom_items() method as of 2023-05-29
"""
bom_items = set()
# Get all BOM items which directly reference the part
for bom_item in BomItem.objects.filter(part=part):
bom_items.add(bom_item)
# Get all BOM items which are inherited by the part
parents = Part.objects.filter(
tree_id=part.tree_id,
level__lt=part.level,
lft__lt=part.lft,
rght__gt=part.rght
)
for bom_item in BomItem.objects.filter(part__in=parents, inherited=True):
bom_items.add(bom_item)
return list(bom_items)
def add_lines_to_builds(apps, schema_editor):
"""Create BuildOrderLine objects for existing build orders"""
# Get database models
Build = apps.get_model("build", "Build")
BuildLine = apps.get_model("build", "BuildLine")
Part = apps.get_model("part", "Part")
BomItem = apps.get_model("part", "BomItem")
build_lines = []
builds = Build.objects.all()
if builds.count() > 0:
print(f"Creating BuildOrderLine objects for {builds.count()} existing builds")
for build in builds:
# Create a BuildOrderLine for each BuildItem
bom_items = get_bom_items_for_part(build.part, Part, BomItem)
for item in bom_items:
build_lines.append(
BuildLine(
build=build,
bom_item=item,
quantity=item.quantity * build.quantity,
)
)
if len(build_lines) > 0:
# Construct the new BuildLine objects
BuildLine.objects.bulk_create(build_lines)
print(f"Created {len(build_lines)} BuildOrderLine objects for existing builds")
def remove_build_lines(apps, schema_editor):
"""Remove BuildOrderLine objects from the database"""
# Get database models
BuildLine = apps.get_model("build", "BuildLine")
n = BuildLine.objects.all().count()
BuildLine.objects.all().delete()
if n > 0:
print(f"Removed {n} BuildOrderLine objects")
class Migration(migrations.Migration):
dependencies = [
('build', '0043_buildline'),
]
operations = [
migrations.RunPython(
add_lines_to_builds,
reverse_code=remove_build_lines,
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 3.2.19 on 2023-06-06 10:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('build', '0044_auto_20230528_1410'),
]
operations = [
migrations.AddField(
model_name='builditem',
name='build_line',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='allocations', to='build.buildline'),
),
]

View File

@@ -1,95 +0,0 @@
# Generated by Django 3.2.19 on 2023-06-06 10:33
import logging
from django.db import migrations
logger = logging.getLogger('inventree')
def add_build_line_links(apps, schema_editor):
"""Data migration to add links between BuildLine and BuildItem objects.
Associated model types:
Build: A "Build Order"
BomItem: An individual line in the BOM for Build.part
BuildItem: An individual stock allocation against the Build Order
BuildLine: (new model) an individual line in the Build Order
Goals:
- Find all BuildItem objects which are associated with a Build
- Link them against the relevant BuildLine object
- The BuildLine objects should have been created in 0044_auto_20230528_1410.py
"""
BuildItem = apps.get_model("build", "BuildItem")
BuildLine = apps.get_model("build", "BuildLine")
# Find any existing BuildItem objects
build_items = BuildItem.objects.all()
n_missing = 0
for item in build_items:
# Find the relevant BuildLine object
line = BuildLine.objects.filter(
build=item.build,
bom_item=item.bom_item
).first()
if line is None:
logger.warning(f"BuildLine does not exist for BuildItem {item.pk}")
n_missing += 1
if item.build is None or item.bom_item is None:
continue
# Create one!
line = BuildLine.objects.create(
build=item.build,
bom_item=item.bom_item,
quantity=item.bom_item.quantity * item.build.quantity
)
# Link the BuildItem to the BuildLine
# In the next data migration, we remove the 'build' and 'bom_item' fields from BuildItem
item.build_line = line
item.save()
if build_items.count() > 0:
logger.info(f"add_build_line_links: Updated {build_items.count()} BuildItem objects (added {n_missing})")
def reverse_build_links(apps, schema_editor):
"""Reverse data migration from add_build_line_links
Basically, iterate through each BuildItem and update the links based on the BuildLine
"""
BuildItem = apps.get_model("build", "BuildItem")
items = BuildItem.objects.all()
for item in items:
item.build = item.build_line.build
item.bom_item = item.build_line.bom_item
item.save()
if items.count() > 0:
logger.info(f"reverse_build_links: Updated {items.count()} BuildItem objects")
class Migration(migrations.Migration):
dependencies = [
('build', '0045_builditem_build_line'),
]
operations = [
migrations.RunPython(
add_build_line_links,
reverse_code=reverse_build_links,
)
]

View File

@@ -1,26 +0,0 @@
# Generated by Django 3.2.19 on 2023-06-06 10:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0101_stockitemtestresult_metadata'),
('build', '0046_auto_20230606_1033'),
]
operations = [
migrations.AlterUniqueTogether(
name='builditem',
unique_together={('build_line', 'stock_item', 'install_into')},
),
migrations.RemoveField(
model_name='builditem',
name='bom_item',
),
migrations.RemoveField(
model_name='builditem',
name='build',
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 3.2.19 on 2023-05-14 09:22
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('common', '0019_projectcode_metadata'),
('build', '0047_auto_20230606_1058'),
]
operations = [
migrations.AddField(
model_name='build',
name='project_code',
field=models.ForeignKey(blank=True, help_text='Project code for this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.projectcode', verbose_name='Project Code'),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,8 @@ from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _
from django.db import models
from django.db.models import ExpressionWrapper, F, FloatField
from django.db.models import Case, Sum, When, Value
from django.db.models import Case, When, Value
from django.db.models import BooleanField
from django.db.models.functions import Coalesce
from rest_framework import serializers
from rest_framework.serializers import ValidationError
@@ -20,15 +17,14 @@ import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus
from stock.models import generate_batch_code, StockItem, StockLocation
from stock.models import StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer
from common.serializers import ProjectCodeSerializer
import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
from part.models import BomItem
from part.serializers import PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
from .models import Build, BuildItem, BuildOrderAttachment
class BuildSerializer(InvenTreeModelSerializer):
@@ -50,8 +46,6 @@ class BuildSerializer(InvenTreeModelSerializer):
'parent',
'part',
'part_detail',
'project_code',
'project_code_detail',
'overdue',
'reference',
'sales_order',
@@ -93,13 +87,11 @@ class BuildSerializer(InvenTreeModelSerializer):
barcode_hash = serializers.CharField(read_only=True)
project_code_detail = ProjectCodeSerializer(source='project_code', many=False, read_only=True)
@staticmethod
def annotate_queryset(queryset):
"""Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
The following annotated fields are added:
The following annoted fields are added:
- overdue: True if the build is outstanding *and* the completion date has past
"""
@@ -178,7 +170,7 @@ class BuildOutputSerializer(serializers.Serializer):
if to_complete:
# The build output must have all tracked parts allocated
if not build.is_output_fully_allocated(output):
if not build.is_fully_allocated(output):
# Check if the user has specified that incomplete allocations are ok
accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False))
@@ -189,45 +181,6 @@ class BuildOutputSerializer(serializers.Serializer):
return output
class BuildOutputQuantitySerializer(BuildOutputSerializer):
"""Serializer for a single build output, with additional quantity field"""
class Meta:
"""Serializer metaclass"""
fields = BuildOutputSerializer.Meta.fields + [
'quantity',
]
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
required=True,
label=_('Quantity'),
help_text=_('Enter quantity for build output'),
)
def validate(self, data):
"""Validate the serializer data"""
data = super().validate(data)
output = data.get('output')
quantity = data.get('quantity')
if quantity <= 0:
raise ValidationError({
'quantity': _('Quantity must be greater than zero')
})
if quantity > output.quantity:
raise ValidationError({
'quantity': _("Quantity cannot be greater than the output quantity")
})
return data
class BuildOutputCreateSerializer(serializers.Serializer):
"""Serializer for creating a new BuildOutput against a BuildOrder.
@@ -273,7 +226,6 @@ class BuildOutputCreateSerializer(serializers.Serializer):
batch_code = serializers.CharField(
required=False,
allow_blank=True,
default=generate_batch_code,
label=_('Batch Code'),
help_text=_('Batch code for this build output'),
)
@@ -350,14 +302,12 @@ class BuildOutputCreateSerializer(serializers.Serializer):
auto_allocate = data.get('auto_allocate', False)
build = self.get_build()
user = self.context['request'].user
build.create_build_output(
quantity,
serials=self.serials,
batch=batch_code,
auto_allocate=auto_allocate,
user=user,
)
@@ -399,78 +349,6 @@ class BuildOutputDeleteSerializer(serializers.Serializer):
build.delete_output(output)
class BuildOutputScrapSerializer(serializers.Serializer):
"""DRF serializer for scrapping one or more build outputs"""
class Meta:
"""Serializer metaclass"""
fields = [
'outputs',
'location',
'notes',
]
outputs = BuildOutputQuantitySerializer(
many=True,
required=True,
)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Location'),
help_text=_('Stock location for scrapped outputs'),
)
discard_allocations = serializers.BooleanField(
required=False,
default=False,
label=_('Discard Allocations'),
help_text=_('Discard any stock allocations for scrapped outputs'),
)
notes = serializers.CharField(
label=_('Notes'),
help_text=_('Reason for scrapping build output(s)'),
required=True,
allow_blank=False,
)
def validate(self, data):
"""Perform validation on the serializer data"""
super().validate(data)
outputs = data.get('outputs', [])
if len(outputs) == 0:
raise ValidationError(_("A list of build outputs must be provided"))
return data
def save(self):
"""Save the serializer to scrap the build outputs"""
build = self.context['build']
request = self.context['request']
data = self.validated_data
outputs = data.get('outputs', [])
# Scrap the build outputs
with transaction.atomic():
for item in outputs:
output = item['output']
quantity = item['quantity']
build.scrap_build_output(
output,
quantity,
data.get('location', None),
user=request.user,
notes=data.get('notes', ''),
discard_allocations=data.get('discard_allocations', False)
)
class BuildOutputCompleteSerializer(serializers.Serializer):
"""DRF serializer for completing one or more build outputs."""
@@ -498,8 +376,8 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
)
status = serializers.ChoiceField(
choices=StockStatus.items(),
default=StockStatus.OK.value,
choices=list(StockStatus.items()),
default=StockStatus.OK,
label=_("Status"),
)
@@ -570,7 +448,7 @@ class BuildCancelSerializer(serializers.Serializer):
build = self.context['build']
return {
'has_allocated_stock': build.is_partially_allocated(),
'has_allocated_stock': build.is_partially_allocated(None),
'incomplete_outputs': build.incomplete_count,
'completed_outputs': build.complete_count,
}
@@ -611,7 +489,7 @@ class OverallocationChoice():
TRIM = 'trim'
OPTIONS = {
REJECT: _('Not permitted'),
REJECT: ('Not permitted'),
ACCEPT: _('Accept as consumed by this build order'),
TRIM: _('Deallocate before completing this build order'),
}
@@ -629,8 +507,8 @@ class BuildCompleteSerializer(serializers.Serializer):
build = self.context['build']
return {
'overallocated': build.is_overallocated(),
'allocated': build.are_untracked_parts_allocated,
'overallocated': build.has_overallocated_parts(),
'allocated': build.are_untracked_parts_allocated(),
'remaining': build.remaining,
'incomplete': build.incomplete_count,
}
@@ -647,7 +525,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_overallocated' field is required"""
build = self.context['build']
if build.is_overallocated() and value == OverallocationChoice.REJECT:
if build.has_overallocated_parts(output=None) and value == OverallocationChoice.REJECT:
raise ValidationError(_('Some stock items have been overallocated'))
return value
@@ -663,7 +541,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_unallocated' field is required"""
build = self.context['build']
if not build.are_untracked_parts_allocated and not value:
if not build.are_untracked_parts_allocated() and not value:
raise ValidationError(_('Required stock has not been fully allocated'))
return value
@@ -714,12 +592,12 @@ class BuildUnallocationSerializer(serializers.Serializer):
- bom_item: Filter against a particular BOM line item
"""
build_line = serializers.PrimaryKeyRelatedField(
queryset=BuildLine.objects.all(),
bom_item = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('Build Line'),
label=_('BOM Item'),
)
output = serializers.PrimaryKeyRelatedField(
@@ -750,8 +628,8 @@ class BuildUnallocationSerializer(serializers.Serializer):
data = self.validated_data
build.deallocate_stock(
build_line=data['build_line'],
build.unallocateStock(
bom_item=data['bom_item'],
output=data['output']
)
@@ -762,34 +640,34 @@ class BuildAllocationItemSerializer(serializers.Serializer):
class Meta:
"""Serializer metaclass"""
fields = [
'build_item',
'bom_item',
'stock_item',
'quantity',
'output',
]
build_line = serializers.PrimaryKeyRelatedField(
queryset=BuildLine.objects.all(),
bom_item = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Build Line Item'),
label=_('BOM Item'),
)
def validate_build_line(self, build_line):
def validate_bom_item(self, bom_item):
"""Check if the parts match"""
build = self.context['build']
# BomItem should point to the same 'part' as the parent build
if build.part != build_line.bom_item.part:
if build.part != bom_item.part:
# If not, it may be marked as "inherited" from a parent part
if build_line.bom_item.inherited and build.part in build_line.bom_item.part.get_descendants(include_self=False):
if bom_item.inherited and build.part in bom_item.part.get_descendants(include_self=False):
pass
else:
raise ValidationError(_("bom_item.part must point to the same part as the build order"))
return build_line
return bom_item
stock_item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
@@ -832,7 +710,8 @@ class BuildAllocationItemSerializer(serializers.Serializer):
"""Perform data validation for this item"""
super().validate(data)
build_line = data['build_line']
build = self.context['build']
bom_item = data['bom_item']
stock_item = data['stock_item']
quantity = data['quantity']
output = data.get('output', None)
@@ -854,20 +733,20 @@ class BuildAllocationItemSerializer(serializers.Serializer):
})
# Output *must* be set for trackable parts
if output is None and build_line.bom_item.sub_part.trackable:
if output is None and bom_item.sub_part.trackable:
raise ValidationError({
'output': _('Build output must be specified for allocation of tracked parts'),
})
# Output *cannot* be set for un-tracked parts
if output is not None and not build_line.bom_item.sub_part.trackable:
if output is not None and not bom_item.sub_part.trackable:
raise ValidationError({
'output': _('Build output cannot be specified for allocation of untracked parts'),
})
# Check if this allocation would be unique
if BuildItem.objects.filter(build_line=build_line, stock_item=stock_item, install_into=output).exists():
if BuildItem.objects.filter(build=build, stock_item=stock_item, install_into=output).exists():
raise ValidationError(_('This stock item has already been allocated to this build output'))
return data
@@ -901,21 +780,24 @@ class BuildAllocationSerializer(serializers.Serializer):
items = data.get('items', [])
build = self.context['build']
with transaction.atomic():
for item in items:
build_line = item['build_line']
bom_item = item['bom_item']
stock_item = item['stock_item']
quantity = item['quantity']
output = item.get('output', None)
# Ignore allocation for consumable BOM items
if build_line.bom_item.consumable:
if bom_item.consumable:
continue
try:
# Create a new BuildItem to allocate stock
BuildItem.objects.create(
build_line=build_line,
build=build,
bom_item=bom_item,
stock_item=stock_item,
quantity=quantity,
install_into=output
@@ -997,37 +879,43 @@ class BuildItemSerializer(InvenTreeModelSerializer):
model = BuildItem
fields = [
'pk',
'bom_part',
'build',
'build_line',
'install_into',
'stock_item',
'quantity',
'location_detail',
'part_detail',
'stock_item_detail',
'build_detail',
'install_into',
'location',
'location_detail',
'part',
'part_detail',
'stock_item',
'stock_item_detail',
'quantity'
]
# Annotated fields
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
# Extra (optional) detail fields
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True)
part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True)
build_detail = BuildSerializer(source='build', many=False, read_only=True)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
quantity = InvenTreeDecimalField()
def __init__(self, *args, **kwargs):
"""Determine which extra details fields should be included"""
part_detail = kwargs.pop('part_detail', True)
location_detail = kwargs.pop('location_detail', True)
stock_detail = kwargs.pop('stock_detail', False)
build_detail = kwargs.pop('build_detail', False)
part_detail = kwargs.pop('part_detail', False)
location_detail = kwargs.pop('location_detail', False)
stock_detail = kwargs.pop('stock_detail', False)
super().__init__(*args, **kwargs)
if not build_detail:
self.fields.pop('build_detail')
if not part_detail:
self.fields.pop('part_detail')
@@ -1037,144 +925,6 @@ class BuildItemSerializer(InvenTreeModelSerializer):
if not stock_detail:
self.fields.pop('stock_item_detail')
if not build_detail:
self.fields.pop('build_detail')
class BuildLineSerializer(InvenTreeModelSerializer):
"""Serializer for a BuildItem object."""
class Meta:
"""Serializer metaclass"""
model = BuildLine
fields = [
'pk',
'build',
'bom_item',
'bom_item_detail',
'part_detail',
'quantity',
'allocations',
# Annotated fields
'allocated',
'on_order',
'available_stock',
'available_substitute_stock',
'available_variant_stock',
]
read_only_fields = [
'build',
'bom_item',
'allocations',
]
quantity = serializers.FloatField()
# Foreign key fields
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True)
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True)
allocations = BuildItemSerializer(many=True, read_only=True)
# Annotated (calculated) fields
allocated = serializers.FloatField(read_only=True)
on_order = serializers.FloatField(read_only=True)
available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
@staticmethod
def annotate_queryset(queryset):
"""Add extra annotations to the queryset:
- allocated: Total stock quantity allocated against this build line
- available: Total stock available for allocation against this build line
- on_order: Total stock on order for this build line
"""
# Pre-fetch related fields
queryset = queryset.prefetch_related(
'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations',
'bom_item__substitutes',
'bom_item__substitutes__part__stock_items',
'bom_item__substitutes__part__stock_items__allocations',
'bom_item__substitutes__part__stock_items__sales_order_allocations',
)
# Annotate the "allocated" quantity
# Difficulty: Easy
queryset = queryset.annotate(
allocated=Coalesce(
Sum('allocations__quantity'), 0,
output_field=models.DecimalField()
),
)
ref = 'bom_item__sub_part__'
# Annotate the "on_order" quantity
# Difficulty: Medium
queryset = queryset.annotate(
on_order=part.filters.annotate_on_order_quantity(reference=ref),
)
# Annotate the "available" quantity
# TODO: In the future, this should be refactored.
# TODO: Note that part.serializers.BomItemSerializer also has a similar annotation
queryset = queryset.alias(
total_stock=part.filters.annotate_total_stock(reference=ref),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref),
)
# Calculate 'available_stock' based on previously annotated fields
queryset = queryset.annotate(
available_stock=ExpressionWrapper(
F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
output_field=models.DecimalField(),
)
)
ref = 'bom_item__substitutes__part__'
# Extract similar information for any 'substitute' parts
queryset = queryset.alias(
substitute_stock=part.filters.annotate_total_stock(reference=ref),
substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref),
substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref)
)
# Calculate 'available_substitute_stock' field
queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
output_field=models.DecimalField(),
)
)
# Annotate the queryset with 'available variant stock' information
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__')
queryset = queryset.alias(
variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
variant_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
variant_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'),
)
queryset = queryset.annotate(
available_variant_stock=ExpressionWrapper(
F('variant_stock_total') - F('variant_bo_allocations') - F('variant_so_allocations'),
output_field=FloatField(),
)
)
return queryset
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for a BuildAttachment."""

View File

@@ -12,10 +12,9 @@ from allauth.account.models import EmailAddress
from plugin.events import trigger_event
import common.notifications
import build.models
import InvenTree.email
import InvenTree.helpers_model
import InvenTree.helpers
import InvenTree.tasks
from InvenTree.status_codes import BuildStatusGroups
from InvenTree.status_codes import BuildStatus
from InvenTree.ready import isImportingData
import part.models as part_models
@@ -24,55 +23,6 @@ import part.models as part_models
logger = logging.getLogger('inventree')
def update_build_order_lines(bom_item_pk: int):
"""Update all BuildOrderLineItem objects which reference a particular BomItem.
This task is triggered when a BomItem is created or updated.
"""
logger.info(f"Updating build order lines for BomItem {bom_item_pk}")
bom_item = part_models.BomItem.objects.filter(pk=bom_item_pk).first()
# If the BomItem has been deleted, there is nothing to do
if not bom_item:
return
assemblies = bom_item.get_assemblies()
# Find all active builds which reference any of the parts
builds = build.models.Build.objects.filter(
part__in=list(assemblies),
status__in=BuildStatusGroups.ACTIVE_CODES
)
# Iterate through each build, and update the relevant line items
for bo in builds:
# Try to find a matching build order line
line = build.models.BuildLine.objects.filter(
build=bo,
bom_item=bom_item,
).first()
q = bom_item.get_required_quantity(bo.quantity)
if line:
# Ensure quantity is correct
if line.quantity != q:
line.quantity = q
line.save()
else:
# Create a new line item
build.models.BuildLine.objects.create(
build=bo,
bom_item=bom_item,
quantity=q,
)
if builds.count() > 0:
logger.info(f"Updated {builds.count()} build orders for part {bom_item.part}")
def check_build_stock(build: build.models.Build):
"""Check the required stock for a newly created build order.
@@ -114,7 +64,7 @@ def check_build_stock(build: build.models.Build):
# There is not sufficient stock for this part
lines.append({
'link': InvenTree.helpers_model.construct_absolute_url(sub_part.get_absolute_url()),
'link': InvenTree.helpers.construct_absolute_url(sub_part.get_absolute_url()),
'part': sub_part,
'in_stock': in_stock,
'allocated': allocated,
@@ -138,7 +88,7 @@ def check_build_stock(build: build.models.Build):
logger.info(f"Notifying users of stock required for build {build.pk}")
context = {
'link': InvenTree.helpers_model.construct_absolute_url(build.get_absolute_url()),
'link': InvenTree.helpers.construct_absolute_url(build.get_absolute_url()),
'build': build,
'part': build.part,
'lines': lines,
@@ -151,7 +101,7 @@ def check_build_stock(build: build.models.Build):
recipients = emails.values_list('email', flat=True)
InvenTree.email.send_email(subject, '', recipients, html_message=html_message)
InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)
def notify_overdue_build_order(bo: build.models.Build):
@@ -171,7 +121,7 @@ def notify_overdue_build_order(bo: build.models.Build):
'order': bo,
'name': name,
'message': _(f"Build order {bo} is now overdue"),
'link': InvenTree.helpers_model.construct_absolute_url(
'link': InvenTree.helpers.construct_absolute_url(
bo.get_absolute_url(),
),
'template': {
@@ -207,7 +157,7 @@ def check_overdue_build_orders():
overdue_orders = build.models.Build.objects.filter(
target_date=yesterday,
status__in=BuildStatusGroups.ACTIVE_CODES
status__in=BuildStatus.ACTIVE_CODES
)
for bo in overdue_orders:

View File

@@ -2,12 +2,12 @@
{% load static %}
{% load i18n %}
{% load generic %}
{% load status_codes %}
{% load inventree_extras %}
{% block page_title %}
{% inventree_title %} | {% trans "Build Order" %} - {{ build }}
{% endblock page_title %}
{% endblock %}
{% block breadcrumbs %}
<li class='breadcrumb-item'><a href='{% url "build-index" %}'>{% trans "Build Orders" %}</a></li>
@@ -15,7 +15,7 @@
{% endblock breadcrumbs %}
{% block thumbnail %}
<img alt="{% trans "Part thumbnail" %}" class="part-thumb"
<img class="part-thumb"
{% if build.part.image %}
src="{{ build.part.image.preview.url }}"
{% else %}
@@ -25,7 +25,7 @@ src="{% static 'img/blank_image.png' %}"
{% block heading %}
{% trans "Build Order" %} {{ build }}
{% endblock heading %}
{% endblock %}
{% block actions %}
<!-- Admin Display -->
@@ -108,7 +108,6 @@ src="{% static 'img/blank_image.png' %}"
<td>{% trans "Build Description" %}</td>
<td>{{ build.title }}</td>
</tr>
{% include "project_code_data.html" with instance=build %}
{% include "barcode_data.html" with instance=build %}
</table>
@@ -118,6 +117,12 @@ src="{% static 'img/blank_image.png' %}"
{% trans "No build outputs have been created for this build order" %}<br>
</div>
{% endif %}
{% if build.parent %}
<div class='alert alert-block alert-info'>
{% object_link 'build-detail' build.parent.id build.parent as link %}
{% blocktrans %}This Build Order is a child of Build Order {{link}}{% endblocktrans %}
</div>
{% endif %}
{% if build.active %}
{% if build.can_complete %}
@@ -142,7 +147,7 @@ src="{% static 'img/blank_image.png' %}"
{% endif %}
{% endif %}
</div>
{% endblock details %}
{% endblock %}
{% block details_right %}
<table class='table table-striped table-condensed'>
@@ -151,7 +156,7 @@ src="{% static 'img/blank_image.png' %}"
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>
{% status_label 'build' build.status %}
{% build_status_label build.status %}
</td>
</tr>
{% if build.target_date %}
@@ -175,7 +180,7 @@ src="{% static 'img/blank_image.png' %}"
{% else %}
<span class='fa fa-times-circle icon-red'></span>
{% endif %}
<td>{% trans "Completed Outputs" %}</td>
<td>{% trans "Completed" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td>
</tr>
{% if build.parent %}
@@ -214,11 +219,11 @@ src="{% static 'img/blank_image.png' %}"
</tr>
{% endif %}
</table>
{% endblock details_right %}
{% endblock %}
{% block page_data %}
<h3>
{% status_label 'build' build.status large=True %}
{% build_status_label build.status large=True %}
{% if build.is_overdue %}
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
{% endif %}
@@ -226,7 +231,8 @@ src="{% static 'img/blank_image.png' %}"
<hr>
<p>{{ build.title }}</p>
{% endblock page_data %}
{% endblock %}
{% block js_ready %}
@@ -282,7 +288,7 @@ src="{% static 'img/blank_image.png' %}"
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Build Order QR Code" %}',
'{"build": {{ build.pk }} }'
'{"build": {{ build.pk }}}'
);
});
@@ -306,4 +312,4 @@ src="{% static 'img/blank_image.png' %}"
{% endif %}
{% endif %}
{% endblock js_ready %}
{% endblock %}

View File

@@ -2,11 +2,11 @@
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% load generic %}
{% load status_codes %}
{% block sidebar %}
{% include "build/sidebar.html" %}
{% endblock sidebar %}
{% endblock %}
{% block page_content %}
@@ -22,12 +22,12 @@
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Description" %}</td>
<td>{{ build.title }}{% include "clip.html" %}</td>
<td>{{ build.title }}{% include "clip.html"%}</td>
</tr>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Part" %}</td>
<td><a href="{% url 'part-detail' build.part.id %}?display=build-orders">{{ build.part.full_name }}</a>{% include "clip.html" %}</td>
<td><a href="{% url 'part-detail' build.part.id %}?display=build-orders">{{ build.part.full_name }}</a>{% include "clip.html"%}</td>
</tr>
<tr>
<td></td>
@@ -38,7 +38,7 @@
<td>{% trans "Stock Source" %}</td>
<td>
{% if build.take_from %}
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>{% include "clip.html" %}
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>{% include "clip.html"%}
{% else %}
<em>{% trans "Stock can be taken from any available location." %}</em>
{% endif %}
@@ -51,7 +51,7 @@
{% if build.destination %}
<a href="{% url 'stock-location-detail' build.destination.id %}">
{{ build.destination }}
</a>{% include "clip.html" %}
</a>{% include "clip.html"%}
{% else %}
<em>{% trans "Destination location not specified" %}</em>
{% endif %}
@@ -60,14 +60,14 @@
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% status_label 'build' build.status %}</td>
<td>{% build_status_label build.status %}</td>
</tr>
<tr>
<td><span class='fas fa-check-circle'></span></td>
<td>{% trans "Completed Outputs" %}</td>
<td>{% trans "Completed" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td>
</tr>
{% if build.active %}
{% if build.active and has_untracked_bom_items %}
<tr>
<td><span class='fas fa-list'></span></td>
<td>{% trans "Allocated Parts" %}</td>
@@ -78,14 +78,14 @@
<tr>
<td><span class='fas fa-layer-group'></span></td>
<td>{% trans "Batch" %}</td>
<td>{{ build.batch }}{% include "clip.html" %}</td>
<td>{{ build.batch }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if build.parent %}
<tr>
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Parent Build" %}</td>
<td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a>{% include "clip.html" %}</td>
<td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a>{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if build.priority != 0 %}
@@ -99,14 +99,14 @@
<tr>
<td><span class='fas fa-dolly'></span></td>
<td>{% trans "Sales Order" %}</td>
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a>{% include "clip.html" %}</td>
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a>{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if build.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td>
<td>{% include 'clip_link.html' with link=build.link %}</td>
<td><a href="{{ build.link }}">{{ build.link }}</a>{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if build.issued_by %}
@@ -165,7 +165,9 @@
</div>
<div class='panel-content'>
<div id='child-button-toolbar'>
{% include "filter_list.html" with id='sub-build' %}
<div class='button-toolbar container-fluid float-right'>
{% include "filter_list.html" with id='sub-build' %}
</div>
</div>
<table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#child-button-toolbar'></table>
</div>
@@ -177,9 +179,9 @@
<h4>{% trans "Allocate Stock to Build" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.build.add and build.active %}
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Deallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Deallocate Stock" %}
{% if roles.build.add and build.active and has_untracked_bom_items %}
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
</button>
<button class='btn btn-primary' type='button' id='btn-auto-allocate' title='{% trans "Automatically allocate stock to build" %}'>
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
@@ -197,10 +199,34 @@
</div>
</div>
<div class='panel-content'>
<div id='build-lines-toolbar'>
{% include "filter_list.html" with id='buildlines' %}
{% if has_untracked_bom_items %}
{% if build.active %}
{% if build.are_untracked_parts_allocated %}
<div class='alert alert-block alert-success'>
{% trans "Untracked stock has been fully allocated for this Build Order" %}
</div>
<table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
{% else %}
<div class='alert alert-block alert-danger'>
{% trans "Untracked stock has not been fully allocated for this Build Order" %}
</div>
{% endif %}
{% endif %}
<div id='unallocated-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span>
</button>
{% include "filter_list.html" with id='builditems' %}
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='allocation-table-untracked' data-toolbar='#unallocated-toolbar'></table>
{% else %}
<div class='alert alert-block alert-info'>
{% trans "This Build Order does not have any associated untracked BOM items" %}
</div>
{% endif %}
</div>
</div>
@@ -220,24 +246,37 @@
</div>
<div class='panel-content'>
<div id='build-output-toolbar'>
{% include "filter_list.html" with id='incompletebuilditems' %}
<div class='button-toolbar container-fluid'>
{% if build.active %}
<div class='btn-group'>
<!-- Build output actions -->
<div class='btn-group'>
<button id='output-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Output Actions" %}'>
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
{% if roles.build.add %}
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected build outputs" %}'>
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
</a></li>
{% endif %}
{% if roles.build.delete %}
<li><a class='dropdown-item' href='#' id='multi-output-delete' title='{% trans "Delete selected build outputs" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete outputs" %}
</a></li>
{% endif %}
</ul>
</div>
{% include "filter_list.html" with id='incompletebuilditems' %}
</div>
{% endif %}
</div>
</div>
<table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table>
</div>
</div>
<div class='panel panel-hidden' id='panel-consumed'>
<div class='panel-heading'>
<h4>
{% trans "Consumed Stock" %}
</h4>
</div>
<div class='panel-content'>
{% include "stock_table.html" with read_only=True prefix="consumed-" %}
</div>
</div>
<div class='panel panel-hidden' id='panel-completed'>
<div class='panel-heading'>
<h4>
@@ -246,7 +285,7 @@
</div>
<div class='panel-content'>
{% include "stock_table.html" with prefix="build-" %}
{% include "stock_table.html" with read_only=True prefix="build-" %}
</div>
</div>
@@ -280,32 +319,25 @@
</div>
</div>
{% endblock page_content %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
onPanelLoad('consumed', function() {
loadStockTable($('#consumed-stock-table'), {
filterTarget: '#filter-list-consumed-stock',
params: {
location_detail: true,
part_detail: true,
consumed_by: {{ build.pk }},
in_stock: false,
},
});
});
onPanelLoad('completed', function() {
loadStockTable($("#build-stock-table"), {
filterTarget: '#filter-list-build-stock',
params: {
location_detail: true,
part_detail: true,
build: {{ build.id }},
is_building: false,
},
groupByField: 'location',
buttons: [
'#stock-options',
],
url: "{% url 'api-stock-list' %}",
});
});
@@ -368,15 +400,38 @@ onPanelLoad('outputs', function() {
{% endif %}
});
onPanelLoad('allocate', function() {
// Load the table of line items for this build order
loadBuildLineTable(
"#build-lines-table",
{{ build.pk }},
{}
{% if build.active and has_untracked_bom_items %}
function loadUntrackedStockTable() {
var build_info = {
pk: {{ build.pk }},
part: {{ build.part.pk }},
quantity: {{ build.quantity }},
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
tracked_parts: false,
};
$('#allocation-table-untracked').bootstrapTable('destroy');
// Load allocation table for un-tracked parts
loadBuildOutputAllocationTable(
build_info,
null,
{
search: true,
}
);
}
onPanelLoad('allocate', function() {
loadUntrackedStockTable();
});
{% endif %}
$('#btn-create-output').click(function() {
createBuildOutput(
@@ -398,62 +453,70 @@ $("#btn-auto-allocate").on('click', function() {
{% if build.take_from %}
location: {{ build.take_from.pk }},
{% endif %}
onSuccess: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
onSuccess: loadUntrackedStockTable,
}
);
});
function allocateSelectedLines() {
$("#btn-allocate").on('click', function() {
let data = getTableData('#build-lines-table');
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
let unallocated_lines = [];
var incomplete_bom_items = [];
data.forEach(function(line) {
if (line.allocated < line.quantity) {
unallocated_lines.push(line);
bom_items.forEach(function(bom_item) {
if (bom_item.required > bom_item.allocated) {
incomplete_bom_items.push(bom_item);
}
});
if (unallocated_lines.length == 0) {
if (incomplete_bom_items.length == 0) {
showAlertDialog(
'{% trans "Allocation Complete" %}',
'{% trans "All lines have been fully allocated" %}',
'{% trans "All untracked stock items have been allocated" %}',
);
} else {
allocateStockToBuild(
{{ build.pk }},
unallocated_lines,
{{ build.part.pk }},
incomplete_bom_items,
{
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
success: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
success: loadUntrackedStockTable,
}
);
}
}
});
$('#btn-unallocate').on('click', function() {
deallocateStock({{ build.id }}, {
unallocateStock({{ build.id }}, {
table: '#allocation-table-untracked',
onSuccess: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
onSuccess: loadUntrackedStockTable,
});
});
$("#btn-allocate").on('click', function() {
allocateSelectedLines();
$('#allocate-selected-items').click(function() {
var bom_items = getTableData('#allocation-table-untracked');
allocateStockToBuild(
{{ build.pk }},
{{ build.part.pk }},
bom_items,
{
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
success: loadUntrackedStockTable,
}
);
});
{% endif %}
enableSidebar('buildorder');
{% endblock js_ready %}
{% endblock %}

View File

@@ -6,11 +6,11 @@
{% block page_title %}
{% inventree_title %} | {% trans "Build Orders" %}
{% endblock page_title %}
{% endblock %}
{% block heading %}
{% trans "Build Orders" %}
{% endblock heading %}
{% endblock %}
{% block actions %}
{% if roles.build.add %}
@@ -18,20 +18,24 @@
<span class='fas fa-tools'></span> {% trans "New Build Order" %}
</button>
{% endif %}
{% endblock actions %}
{% endblock %}
{% block page_info %}
<div class='panel-content'>
<div id='button-toolbar'>
{% include "filter_list.html" with id="build" %}
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="build" %}
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>
</table>
</div>
{% endblock page_info %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
@@ -44,4 +48,4 @@ loadBuildTable($("#build-table"), {
locale: '{{ request.LANGUAGE_CODE }}',
});
{% endblock js_ready %}
{% endblock %}

View File

@@ -4,16 +4,16 @@
{% trans "Build Order Details" as text %}
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
{% if build.is_active %}
{% if build.active %}
{% trans "Allocate Stock" as text %}
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
{% endif %}
{% if not build.is_complete %}
{% trans "Incomplete Outputs" as text %}
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
{% endif %}
{% trans "Completed Outputs" as text %}
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
{% trans "Consumed Stock" as text %}
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
{% trans "Child Build Orders" as text %}
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
{% trans "Attachments" as text %}

View File

@@ -10,8 +10,8 @@ from part.models import Part
from build.models import Build, BuildItem
from stock.models import StockItem
from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.unit_test import InvenTreeAPITestCase
from InvenTree.status_codes import BuildStatus
from InvenTree.api_tester import InvenTreeAPITestCase
class TestBuildAPI(InvenTreeAPITestCase):
@@ -298,7 +298,7 @@ class BuildTest(BuildAPITest):
expected_code=400,
)
bo.status = BuildStatus.CANCELLED.value
bo.status = BuildStatus.CANCELLED
bo.save()
# Now, we should be able to delete
@@ -541,10 +541,10 @@ class BuildTest(BuildAPITest):
{
'export': 'csv',
}
) as file:
) as fo:
data = self.process_csv(
file,
fo,
required_cols=required_cols,
excluded_cols=excluded_cols,
required_rows=Build.objects.count()
@@ -582,9 +582,6 @@ class BuildAllocationTest(BuildAPITest):
self.build = Build.objects.get(pk=1)
# Regenerate BuildLine objects
self.build.create_build_line_items()
# Record number of build items which exist at the start of each test
self.n = BuildItem.objects.count()
@@ -596,7 +593,7 @@ class BuildAllocationTest(BuildAPITest):
self.assertEqual(self.build.part.bom_items.count(), 4)
# No items yet allocated to this build
self.assertEqual(BuildItem.objects.filter(build_line__build=self.build).count(), 0)
self.assertEqual(self.build.allocated_stock.count(), 0)
def test_get(self):
"""A GET request to the endpoint should return an error."""
@@ -637,7 +634,7 @@ class BuildAllocationTest(BuildAPITest):
{
"items": [
{
"build_line": 1, # M2x4 LPHS
"bom_item": 1, # M2x4 LPHS
"stock_item": 2, # 5,000 screws available
}
]
@@ -661,7 +658,7 @@ class BuildAllocationTest(BuildAPITest):
expected_code=400
).data
self.assertIn("This field is required", str(data["items"][0]["build_line"]))
self.assertIn("This field is required", str(data["items"][0]["bom_item"]))
# Missing stock_item
data = self.post(
@@ -669,7 +666,7 @@ class BuildAllocationTest(BuildAPITest):
{
"items": [
{
"build_line": 1,
"bom_item": 1,
"quantity": 5000,
}
]
@@ -684,25 +681,12 @@ class BuildAllocationTest(BuildAPITest):
def test_invalid_bom_item(self):
"""Test by passing an invalid BOM item."""
# Find the right (in this case, wrong) BuildLine instance
si = StockItem.objects.get(pk=11)
lines = self.build.build_lines.all()
wrong_line = None
for line in lines:
if line.bom_item.sub_part.pk != si.pk:
wrong_line = line
break
data = self.post(
self.url,
{
"items": [
{
"build_line": wrong_line.pk,
"bom_item": 5,
"stock_item": 11,
"quantity": 500,
}
@@ -711,31 +695,19 @@ class BuildAllocationTest(BuildAPITest):
expected_code=400
).data
self.assertIn('Selected stock item does not match BOM line', str(data))
self.assertIn('must point to the same part', str(data))
def test_valid_data(self):
"""Test with valid data.
This should result in creation of a new BuildItem object
"""
# Find the correct BuildLine
si = StockItem.objects.get(pk=2)
right_line = None
for line in self.build.build_lines.all():
if line.bom_item.sub_part.pk == si.part.pk:
right_line = line
break
self.post(
self.url,
{
"items": [
{
"build_line": right_line.pk,
"bom_item": 1,
"stock_item": 2,
"quantity": 5000,
}
@@ -777,22 +749,16 @@ class BuildOverallocationTest(BuildAPITest):
cls.state = {}
cls.allocation = {}
items_to_create = []
for i, bi in enumerate(cls.build.part.bom_items.all()):
rq = cls.build.required_quantity(bi, None) + i + 1
si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first()
for idx, build_line in enumerate(cls.build.build_lines.all()):
required = build_line.quantity + idx + 1
sub_part = build_line.bom_item.sub_part
si = StockItem.objects.filter(part=sub_part, quantity__gte=required).first()
cls.state[sub_part] = (si, si.quantity, required)
items_to_create.append(BuildItem(
build_line=build_line,
cls.state[bi.sub_part] = (si, si.quantity, rq)
BuildItem.objects.create(
build=cls.build,
stock_item=si,
quantity=required,
))
BuildItem.objects.bulk_create(items_to_create)
quantity=rq,
)
# create and complete outputs
cls.build.create_build_output(cls.build.quantity)
@@ -856,10 +822,9 @@ class BuildOverallocationTest(BuildAPITest):
self.assertTrue(self.build.is_complete)
# Check stock items have reduced only by bom requirement (overallocation trimmed)
for line in self.build.build_lines.all():
si, oq, _ = self.state[line.bom_item.sub_part]
rq = line.quantity
for bi in self.build.part.bom_items.all():
si, oq, _ = self.state[bi.sub_part]
rq = self.build.required_quantity(bi, None)
si.refresh_from_db()
self.assertEqual(si.quantity, oq - rq)
@@ -878,7 +843,7 @@ class BuildListTest(BuildAPITest):
builds = self.get(self.url, data={'active': True})
self.assertEqual(len(builds.data), 1)
builds = self.get(self.url, data={'status': BuildStatus.COMPLETE.value})
builds = self.get(self.url, data={'status': BuildStatus.COMPLETE})
self.assertEqual(len(builds.data), 4)
builds = self.get(self.url, data={'overdue': False})
@@ -898,7 +863,7 @@ class BuildListTest(BuildAPITest):
reference="BO-0006",
quantity=10,
title='Just some thing',
status=BuildStatus.PRODUCTION.value,
status=BuildStatus.PRODUCTION,
target_date=in_the_past
)
@@ -959,130 +924,3 @@ class BuildListTest(BuildAPITest):
builds = response.data
self.assertEqual(len(builds), 20)
class BuildOutputScrapTest(BuildAPITest):
"""Unit tests for scrapping build outputs"""
def scrap(self, build_id, data, expected_code=None):
"""Helper method to POST to the scrap API"""
url = reverse('api-build-output-scrap', kwargs={'pk': build_id})
response = self.post(url, data, expected_code=expected_code)
return response.data
def test_invalid_scraps(self):
"""Test that invalid scrap attempts are rejected"""
# Test with missing required fields
response = self.scrap(1, {}, expected_code=400)
for field in ['outputs', 'location', 'notes']:
self.assertIn('This field is required', str(response[field]))
# Scrap with no outputs specified
response = self.scrap(
1,
{
'outputs': [],
'location': 1,
'notes': 'Should fail',
}
)
self.assertIn('A list of build outputs must be provided', str(response))
# Scrap with an invalid output ID
response = self.scrap(
1,
{
'outputs': [
{
'output': 9999,
}
],
'location': 1,
'notes': 'Should fail',
},
expected_code=400
)
self.assertIn('object does not exist', str(response['outputs']))
# Create a build output, for a different build
build = Build.objects.get(pk=2)
output = StockItem.objects.create(
part=build.part,
quantity=10,
batch='BATCH-TEST',
is_building=True,
build=build,
)
response = self.scrap(
1,
{
'outputs': [
{
'output': output.pk,
},
],
'location': 1,
'notes': 'Should fail',
},
expected_code=400
)
self.assertIn("Build output does not match the parent build", str(response['outputs']))
def test_valid_scraps(self):
"""Test that valid scrap attempts succeed"""
# Create a build output
build = Build.objects.get(pk=1)
for _ in range(3):
build.create_build_output(2)
outputs = build.build_outputs.all()
self.assertEqual(outputs.count(), 3)
self.assertEqual(StockItem.objects.filter(build=build).count(), 3)
for output in outputs:
self.assertEqual(output.status, StockStatus.OK)
self.assertTrue(output.is_building)
# Scrap all three outputs
self.scrap(
1,
{
'outputs': [
{
'output': outputs[0].pk,
'quantity': outputs[0].quantity,
},
{
'output': outputs[1].pk,
'quantity': outputs[1].quantity,
},
{
'output': outputs[2].pk,
'quantity': outputs[2].quantity,
},
],
'location': 1,
'notes': 'Should succeed',
},
expected_code=201
)
# There should still be three outputs associated with this build
self.assertEqual(StockItem.objects.filter(build=build).count(), 3)
for output in outputs:
output.refresh_from_db()
self.assertEqual(output.status, StockStatus.REJECTED)
self.assertFalse(output.is_building)

View File

@@ -13,7 +13,7 @@ from InvenTree import status_codes as status
import common.models
import build.tasks
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from build.models import Build, BuildItem, generate_next_build_reference
from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem
from users.models import Owner
@@ -107,11 +107,6 @@ class BuildTestBase(TestCase):
issued_by=get_user_model().objects.get(pk=1),
)
# Create some BuildLine items we can use later on
cls.line_1 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_1)
cls.line_2 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_2)
cls.line_3 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_3)
# Create some build output (StockItem) objects
cls.output_1 = StockItem.objects.create(
part=cls.assembly,
@@ -146,7 +141,6 @@ class BuildTest(BuildTestBase):
def test_ref_int(self):
"""Test the "integer reference" field used for natural sorting"""
# Set build reference to new value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
refs = {
@@ -169,9 +163,6 @@ class BuildTest(BuildTestBase):
build.save()
self.assertEqual(build.reference_int, ref_int)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_ref_validation(self):
"""Test that the reference field validation works as expected"""
@@ -218,9 +209,6 @@ class BuildTest(BuildTestBase):
title='Valid reference',
)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_next_ref(self):
"""Test that the next reference is automatically generated"""
@@ -245,9 +233,6 @@ class BuildTest(BuildTestBase):
self.assertEqual(build.reference, 'XYZ-000988')
self.assertEqual(build.reference_int, 988)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_init(self):
"""Perform some basic tests before we start the ball rolling"""
@@ -263,10 +248,13 @@ class BuildTest(BuildTestBase):
for output in self.build.get_build_outputs().all():
self.assertFalse(self.build.is_fully_allocated(output))
self.assertFalse(self.line_1.is_fully_allocated())
self.assertFalse(self.line_2.is_overallocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1, self.output_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2, self.output_2))
self.assertEqual(self.line_1.allocated_quantity(), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_2), 35)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_1), 9)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_2), 21)
self.assertFalse(self.build.is_complete)
@@ -276,25 +264,25 @@ class BuildTest(BuildTestBase):
stock = StockItem.objects.create(part=self.assembly, quantity=99)
# Create a BuiltItem which points to an invalid StockItem
b = BuildItem(stock_item=stock, build_line=self.line_2, quantity=10)
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
with self.assertRaises(ValidationError):
b.save()
# Create a BuildItem which has too much stock assigned
b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999)
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999)
with self.assertRaises(ValidationError):
b.clean()
# Negative stock? Not on my watch!
b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=-99)
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=-99)
with self.assertRaises(ValidationError):
b.clean()
# Ok, what about we make one that does *not* fail?
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
b.save()
def test_duplicate_bom_line(self):
@@ -314,24 +302,13 @@ class BuildTest(BuildTestBase):
allocations: Map of {StockItem: quantity}
"""
items_to_create = []
for item, quantity in allocations.items():
# Find an appropriate BuildLine to allocate against
line = BuildLine.objects.filter(
BuildItem.objects.create(
build=self.build,
bom_item__sub_part=item.part
).first()
items_to_create.append(BuildItem(
build_line=line,
stock_item=item,
quantity=quantity,
install_into=output
))
BuildItem.objects.bulk_create(items_to_create)
)
def test_partial_allocation(self):
"""Test partial allocation of stock"""
@@ -344,7 +321,7 @@ class BuildTest(BuildTestBase):
}
)
self.assertTrue(self.build.is_output_fully_allocated(self.output_1))
self.assertTrue(self.build.is_fully_allocated(self.output_1))
# Partially allocate tracked stock against build output 2
self.allocate_stock(
@@ -354,7 +331,7 @@ class BuildTest(BuildTestBase):
}
)
self.assertFalse(self.build.is_output_fully_allocated(self.output_2))
self.assertFalse(self.build.is_fully_allocated(self.output_2))
# Partially allocate untracked stock against build
self.allocate_stock(
@@ -365,12 +342,11 @@ class BuildTest(BuildTestBase):
}
)
self.assertFalse(self.build.is_output_fully_allocated(None))
self.assertFalse(self.build.is_fully_allocated(None))
# Find lines which are *not* fully allocated
unallocated = self.build.unallocated_lines()
unallocated = self.build.unallocated_bom_items(None)
self.assertEqual(len(unallocated), 3)
self.assertEqual(len(unallocated), 2)
self.allocate_stock(
None,
@@ -381,17 +357,17 @@ class BuildTest(BuildTestBase):
self.assertFalse(self.build.is_fully_allocated(None))
unallocated = self.build.unallocated_lines()
unallocated = self.build.unallocated_bom_items(None)
self.assertEqual(len(unallocated), 1)
self.build.unallocateStock()
unallocated = self.build.unallocated_bom_items(None)
self.assertEqual(len(unallocated), 2)
self.build.deallocate_stock()
unallocated = self.build.unallocated_lines(None)
self.assertEqual(len(unallocated), 3)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertFalse(self.build.are_untracked_parts_allocated())
self.stock_2_1.quantity = 500
self.stock_2_1.save()
@@ -405,7 +381,7 @@ class BuildTest(BuildTestBase):
}
)
self.assertTrue(self.build.is_fully_allocated(tracked=False))
self.assertTrue(self.build.are_untracked_parts_allocated())
def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function"""
@@ -448,40 +424,24 @@ class BuildTest(BuildTestBase):
extra_2_2: 4, # 35
}
)
self.assertTrue(self.build.is_overallocated())
self.assertTrue(self.build.has_overallocated_parts(None))
self.build.trim_allocated_stock()
self.assertFalse(self.build.is_overallocated())
self.assertFalse(self.build.has_overallocated_parts(None))
self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None)
self.assertTrue(self.build.can_complete)
n = StockItem.objects.filter(consumed_by=self.build).count()
self.build.complete_build(None)
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
# Check stock items are in expected state.
self.assertEqual(StockItem.objects.get(pk=self.stock_1_2.pk).quantity, 53)
# Total stock quantity has not been decreased
items = StockItem.objects.filter(part=self.sub_part_2)
self.assertEqual(items.aggregate(Sum('quantity'))['quantity__sum'], 35)
# However, the "available" stock quantity has been decreased
self.assertEqual(items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], 5)
# And the "consumed_by" quantity has been increased
self.assertEqual(items.filter(consumed_by=self.build).aggregate(Sum('quantity'))['quantity__sum'], 30)
self.assertEqual(StockItem.objects.filter(part=self.sub_part_2).aggregate(Sum('quantity'))['quantity__sum'], 5)
self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
# Check that the "consumed_by" item count has increased
self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8)
def test_cancel(self):
"""Test cancellation of the build"""
@@ -550,12 +510,15 @@ class BuildTest(BuildTestBase):
self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 13)
self.assertEqual(StockItem.objects.count(), 10)
# This stock item has been marked as "consumed"
item = StockItem.objects.get(pk=self.stock_1_1.pk)
self.assertIsNotNone(item.consumed_by)
self.assertFalse(item.in_stock)
# This stock item has been depleted!
with self.assertRaises(StockItem.DoesNotExist):
StockItem.objects.get(pk=self.stock_1_1.pk)
# This stock item has also been depleted
with self.assertRaises(StockItem.DoesNotExist):
StockItem.objects.get(pk=self.stock_2_1.pk)
# And 10 new stock items created for the build output
outputs = StockItem.objects.filter(build=self.build)
@@ -611,12 +574,12 @@ class BuildTest(BuildTestBase):
"""Unit tests for the metadata field."""
# Make sure a BuildItem exists before trying to run this test
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
b.save()
for model in [Build, BuildItem]:
p = model.objects.first()
self.assertEqual(len(p.metadata.keys()), 0)
self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
@@ -668,7 +631,7 @@ class AutoAllocationTests(BuildTestBase):
# No build item allocations have been made against the build
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertFalse(self.build.are_untracked_parts_allocated())
# Stock is not interchangeable, nothing will happen
self.build.auto_allocate_stock(
@@ -676,15 +639,15 @@ class AutoAllocationTests(BuildTestBase):
substitutes=False,
)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertFalse(self.build.are_untracked_parts_allocated())
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.line_1.is_fully_allocated())
self.assertFalse(self.line_2.is_fully_allocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
self.assertEqual(self.line_1.unallocated_quantity(), 50)
self.assertEqual(self.line_2.unallocated_quantity(), 30)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30)
# This time we expect stock to be allocated!
self.build.auto_allocate_stock(
@@ -693,27 +656,28 @@ class AutoAllocationTests(BuildTestBase):
optional_items=True,
)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertFalse(self.build.are_untracked_parts_allocated())
self.assertEqual(self.build.allocated_stock.count(), 7)
self.assertTrue(self.line_1.is_fully_allocated())
self.assertFalse(self.line_2.is_fully_allocated())
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 5)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5)
# This time, allow substitute parts to be used!
# This time, allow substitue parts to be used!
self.build.auto_allocate_stock(
interchangeable=True,
substitutes=True,
)
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 5)
# self.assertEqual(self.build.allocated_stock.count(), 8)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5.0)
self.assertTrue(self.line_1.is_fully_allocated())
self.assertFalse(self.line_2.is_fully_allocated())
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
def test_fully_auto(self):
"""We should be able to auto-allocate against a build in a single go"""
@@ -724,7 +688,7 @@ class AutoAllocationTests(BuildTestBase):
optional_items=True,
)
self.assertTrue(self.build.is_fully_allocated(tracked=False))
self.assertTrue(self.build.are_untracked_parts_allocated())
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)

View File

@@ -2,14 +2,14 @@
from django_test_migrations.contrib.unittest_case import MigratorTestCase
from InvenTree import unit_test
from InvenTree import helpers
class TestForwardMigrations(MigratorTestCase):
"""Test entire schema migration sequence for the build app."""
migrate_from = ('build', unit_test.getOldestMigrationFile('build'))
migrate_to = ('build', unit_test.getNewestMigrationFile('build'))
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
migrate_to = ('build', helpers.getNewestMigrationFile('build'))
def prepare(self):
"""Create initial data!"""
@@ -19,15 +19,22 @@ class TestForwardMigrations(MigratorTestCase):
name='Widget',
description='Buildable Part',
active=True,
level=0, lft=0, rght=0, tree_id=0,
)
with self.assertRaises(TypeError):
# Cannot set the 'assembly' field as it hasn't been added to the db schema
Part.objects.create(
name='Blorb',
description='ABCDE',
assembly=True
)
Build = self.old_state.apps.get_model('build', 'build')
Build.objects.create(
part=buildable_part,
title='A build of some stuff',
quantity=50,
quantity=50
)
def test_items_exist(self):
@@ -51,7 +58,7 @@ class TestForwardMigrations(MigratorTestCase):
class TestReferenceMigration(MigratorTestCase):
"""Test custom migration which adds 'reference' field to Build model."""
migrate_from = ('build', unit_test.getOldestMigrationFile('build'))
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
migrate_to = ('build', '0018_build_reference')
def prepare(self):
@@ -60,8 +67,7 @@ class TestReferenceMigration(MigratorTestCase):
part = Part.objects.create(
name='Part',
description='A test part',
level=0, lft=0, rght=0, tree_id=0,
description='A test part'
)
Build = self.old_state.apps.get_model('build', 'build')
@@ -107,7 +113,7 @@ class TestReferencePatternMigration(MigratorTestCase):
"""
migrate_from = ('build', '0019_auto_20201019_1302')
migrate_to = ('build', unit_test.getNewestMigrationFile('build'))
migrate_to = ('build', helpers.getNewestMigrationFile('build'))
def prepare(self):
"""Create some initial data prior to migration"""
@@ -152,139 +158,3 @@ class TestReferencePatternMigration(MigratorTestCase):
pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN')
self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}')
class TestBuildLineCreation(MigratorTestCase):
"""Test that build lines are correctly created for existing builds.
Ref: https://github.com/inventree/InvenTree/pull/4855
This PR added the 'BuildLine' model, which acts as a link between a Build and a BomItem.
- Migration 0044 creates BuildLine objects for existing builds.
- Migration 0046 links any existing BuildItem objects to corresponding BuildLine
"""
migrate_from = ('build', '0041_alter_build_title')
migrate_to = ('build', '0047_auto_20230606_1058')
def prepare(self):
"""Create data to work with"""
# Model references
Part = self.old_state.apps.get_model('part', 'part')
BomItem = self.old_state.apps.get_model('part', 'bomitem')
Build = self.old_state.apps.get_model('build', 'build')
BuildItem = self.old_state.apps.get_model('build', 'builditem')
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
# The "BuildLine" model does not exist yet
with self.assertRaises(LookupError):
self.old_state.apps.get_model('build', 'buildline')
# Create a part
assembly = Part.objects.create(
name='Assembly',
description='An assembly',
assembly=True,
level=0, lft=0, rght=0, tree_id=0,
)
# Create components
for idx in range(1, 11):
part = Part.objects.create(
name=f"Part {idx}",
description=f"Part {idx}",
level=0, lft=0, rght=0, tree_id=0,
)
# Create plentiful stock
StockItem.objects.create(
part=part,
quantity=1000,
level=0, lft=0, rght=0, tree_id=0,
)
# Create a BOM item
BomItem.objects.create(
part=assembly,
sub_part=part,
quantity=idx,
reference=f"REF-{idx}",
)
# Create some builds
for idx in range(1, 4):
build = Build.objects.create(
part=assembly,
title=f"Build {idx}",
quantity=idx * 10,
reference=f"REF-{idx}",
level=0, lft=0, rght=0, tree_id=0,
)
# Allocate stock to the build
for bom_item in BomItem.objects.all():
stock_item = StockItem.objects.get(part=bom_item.sub_part)
BuildItem.objects.create(
build=build,
bom_item=bom_item,
stock_item=stock_item,
quantity=bom_item.quantity,
)
def test_build_line_creation(self):
"""Test that the BuildLine objects have been created correctly"""
Build = self.new_state.apps.get_model('build', 'build')
BomItem = self.new_state.apps.get_model('part', 'bomitem')
BuildLine = self.new_state.apps.get_model('build', 'buildline')
BuildItem = self.new_state.apps.get_model('build', 'builditem')
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
# There should be 3x builds
self.assertEqual(Build.objects.count(), 3)
# 10x BOMItem objects
self.assertEqual(BomItem.objects.count(), 10)
# 10x StockItem objects
self.assertEqual(StockItem.objects.count(), 10)
# And 30x BuildLine items (1 for each BomItem for each Build)
self.assertEqual(BuildLine.objects.count(), 30)
# And 30x BuildItem objects (1 for each BomItem for each Build)
self.assertEqual(BuildItem.objects.count(), 30)
# Check that each BuildItem has been linked to a BuildLine
for item in BuildItem.objects.all():
self.assertIsNotNone(item.build_line)
self.assertEqual(
item.stock_item.part,
item.build_line.bom_item.sub_part,
)
item = BuildItem.objects.first()
# Check that the "build" field has been removed
with self.assertRaises(AttributeError):
item.build
# Check that the "bom_item" field has been removed
with self.assertRaises(AttributeError):
item.bom_item
# Check that each BuildLine is correctly configured
for line in BuildLine.objects.all():
# Check that the quantity is correct
self.assertEqual(
line.quantity,
line.build.quantity * line.bom_item.quantity,
)
# Check that the linked parts are correct
self.assertEqual(
line.build.part,
line.bom_item.part,
)

View File

@@ -4,7 +4,7 @@ from django.urls import reverse
from datetime import datetime, timedelta
from InvenTree.unit_test import InvenTreeTestCase
from InvenTree.helpers import InvenTreeTestCase
from .models import Build
from stock.models import StockItem

View File

@@ -39,5 +39,7 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
part = build.part
ctx['part'] = part
ctx['has_tracked_bom_items'] = build.has_tracked_bom_items()
ctx['has_untracked_bom_items'] = build.has_untracked_bom_items()
return ctx

View File

@@ -2,7 +2,6 @@
import json
from django.conf import settings
from django.http.response import HttpResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
@@ -18,13 +17,13 @@ from rest_framework.views import APIView
import common.models
import common.serializers
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.api import BulkDeleteMixin
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
from InvenTree.helpers import inheritors
from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
from InvenTree.permissions import IsStaffOrReadOnly, IsSuperuser
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI)
from InvenTree.permissions import IsSuperuser
from plugin.models import NotificationUserSetting
from plugin.serializers import NotificationUserSettingSerializer
@@ -46,7 +45,7 @@ class WebhookView(CsrfExemptMixin, APIView):
run_async = False
def post(self, request, endpoint, *args, **kwargs):
"""Process incoming webhook."""
"""Process incomming webhook."""
# get webhook definition
self._get_webhook(endpoint, request, *args, **kwargs)
@@ -122,13 +121,8 @@ class CurrencyExchangeView(APIView):
# Information on last update
try:
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
if backend.exists():
backend = backend.first()
updated = backend.last_update
else:
updated = None
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
updated = backend.last_update
except Exception:
updated = None
@@ -170,7 +164,7 @@ class CurrencyRefreshView(APIView):
class SettingsList(ListAPI):
"""Generic ListView for settings.
This is inherited by all list views for settings.
This is inheritted by all list views for settings.
"""
filter_backends = SEARCH_ORDER_FILTER
@@ -446,69 +440,6 @@ class ConfigDetail(RetrieveAPI):
return {key: value}
class NotesImageList(ListCreateAPI):
"""List view for all notes images."""
queryset = common.models.NotesImage.objects.all()
serializer_class = common.serializers.NotesImageSerializer
permission_classes = [permissions.IsAuthenticated, ]
def perform_create(self, serializer):
"""Create (upload) a new notes image"""
image = serializer.save()
image.user = self.request.user
image.save()
class ProjectCodeList(ListCreateAPI):
"""List view for all project codes."""
queryset = common.models.ProjectCode.objects.all()
serializer_class = common.serializers.ProjectCodeSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = [
'code',
]
search_fields = [
'code',
'description',
]
class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
"""Detail view for a particular project code"""
queryset = common.models.ProjectCode.objects.all()
serializer_class = common.serializers.ProjectCodeSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
class FlagList(ListAPI):
"""List view for feature flags."""
queryset = settings.FLAGS
serializer_class = common.serializers.FlagSerializer
permission_classes = [permissions.AllowAny, ]
class FlagDetail(RetrieveAPI):
"""Detail view for an individual feature flag."""
serializer_class = common.serializers.FlagSerializer
permission_classes = [permissions.AllowAny, ]
def get_object(self):
"""Attempt to find a config object with the provided key."""
key = self.kwargs['key']
value = settings.FLAGS.get(key, None)
if not value:
raise NotFound()
return {key: value}
settings_api_urls = [
# User settings
re_path(r'^user/', include([
@@ -525,7 +456,7 @@ settings_api_urls = [
path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
# Notification Settings List
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notification-setting-list'),
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
])),
# Global settings
@@ -542,18 +473,6 @@ common_api_urls = [
# Webhooks
path('webhook/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'),
# Uploaded images for notes
re_path(r'^notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list'),
# Project codes
re_path(r'^project-code/', include([
path(r'<int:pk>/', include([
re_path(r'^metadata/', MetadataView.as_view(), {'model': common.models.ProjectCode}, name='api-project-code-metadata'),
re_path(r'^.*$', ProjectCodeDetail.as_view(), name='api-project-code-detail'),
])),
re_path(r'^.*$', ProjectCodeList.as_view(), name='api-project-code-list'),
])),
# Currencies
re_path(r'^currency/', include([
re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'),
@@ -581,11 +500,6 @@ common_api_urls = [
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),
])),
# Flags
path('flags/', include([
path('<str:key>/', FlagDetail.as_view(), name='api-flag-detail'),
re_path(r'^.*$', FlagList.as_view(), name='api-flag-list'),
])),
]
admin_api_urls = [

View File

@@ -59,8 +59,7 @@ class FileManager:
# Reset stream position to beginning of file
file.seek(0)
else:
fmt = ext.upper()
raise ValidationError(_(f'Unsupported file format: {fmt}'))
raise ValidationError(_(f'Unsupported file format: {ext.upper()}'))
except UnicodeEncodeError:
raise ValidationError(_('Error reading file (invalid encoding)'))
@@ -84,7 +83,7 @@ class FileManager:
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS
def setup(self):
"""Setup headers should be overridden in usage to set the Different Headers."""
"""Setup headers should be overriden in usage to set the Different Headers."""
if not self.name:
return
@@ -181,7 +180,7 @@ class FileManager:
for i in range(self.row_count()):
data = list(self.get_row_data(i))
data = [item for item in self.get_row_data(i)]
# Is the row completely empty? Skip!
empty = True

View File

@@ -46,7 +46,7 @@ class MatchFieldForm(forms.Form):
"""Step 2 of FileManagementFormView."""
def __init__(self, *args, **kwargs):
"""Setup filemanager and check columns."""
"""Setup filemanager and check columsn."""
# Get FileManager
file_manager = None
if 'file_manager' in kwargs:
@@ -106,7 +106,7 @@ class MatchItemForm(forms.Form):
# Set field name
field_name = col_guess.lower() + '-' + str(row['index'])
# check if field def was overridden
# check if field def was overriden
overriden_field = self.get_special_field(col_guess, row, file_manager)
if overriden_field:
self.fields[field_name] = overriden_field
@@ -174,5 +174,5 @@ class MatchItemForm(forms.Form):
)
def get_special_field(self, col_guess, row, file_manager):
"""Function to be overridden in inherited forms to add specific form settings."""
"""Function to be overriden in inherited forms to add specific form settings."""
return None

View File

@@ -4,40 +4,15 @@ import django.core.validators
from django.db import migrations, models
class CreateModelOrSkip(migrations.CreateModel):
"""Custom migration operation to create a model if it does not already exist.
- If the model already exists, the migration is skipped
- This class has been added to deal with some errors being thrown in CI tests
- The 'common_currency' table doesn't exist anymore anyway!
- In the future, these migrations will be squashed
"""
def database_forwards(self, app_label, schema_editor, from_state, to_state) -> None:
"""Forwards migration *attempts* to create the model, but will fail gracefully if it already exists"""
try:
super().database_forwards(app_label, schema_editor, from_state, to_state)
except Exception:
pass
def state_forwards(self, app_label, state) -> None:
try:
super().state_forwards(app_label, state)
except Exception:
pass
class Migration(migrations.Migration):
initial = True
atomic = False
dependencies = [
]
operations = [
CreateModelOrSkip(
migrations.CreateModel(
name='Currency',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),

View File

@@ -8,16 +8,6 @@ def set_default_currency(apps, schema_editor):
""" migrate the currency setting from config.yml to db """
# get value from settings-file
base_currency = get_setting('INVENTREE_BASE_CURRENCY', 'base_currency', 'USD')
from common.settings import currency_codes
# check if value is valid
if base_currency not in currency_codes():
if len (currency_codes()) > 0:
base_currency = currency_codes()[0]
else:
base_currency = 'USD'
# write to database
InvenTreeSetting.set_setting('INVENTREE_DEFAULT_CURRENCY', base_currency, None, create=True)

View File

@@ -1,27 +0,0 @@
# Generated by Django 3.2.18 on 2023-04-17 05:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import common.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('common', '0016_alter_notificationentry_updated'),
]
operations = [
migrations.CreateModel(
name='NotesImage',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(help_text='Image file', upload_to=common.models.rename_notes_image, verbose_name='Image')),
('date', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 3.2.18 on 2023-04-19 02:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0017_notesimage'),
]
operations = [
migrations.CreateModel(
name='ProjectCode',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(help_text='Unique project code', max_length=50, unique=True, verbose_name='Project Code')),
('description', models.CharField(blank=True, help_text='Project description', max_length=200, verbose_name='Description')),
],
),
]

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