2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-20 03:36:30 +00:00

Merge remote-tracking branch 'upstream/master' into add-ready-checks

This commit is contained in:
wolflu05
2023-07-12 11:27:15 +00:00
295 changed files with 97428 additions and 88416 deletions
.github
.pkgr.yml.pre-commit-config.yamlCONTRIBUTING.mdDockerfile
InvenTree
InvenTree
build
common
company
config_template.yaml
generic
label
locale
cs
LC_MESSAGES
da
LC_MESSAGES
de
LC_MESSAGES
el
LC_MESSAGES
en
LC_MESSAGES
es
LC_MESSAGES
es_MX
LC_MESSAGES
fa
LC_MESSAGES
fi
LC_MESSAGES
fr
LC_MESSAGES
he
LC_MESSAGES
hu
LC_MESSAGES
id
LC_MESSAGES
it
LC_MESSAGES
ja
LC_MESSAGES
ko
LC_MESSAGES
nl
LC_MESSAGES
no
LC_MESSAGES
pl
LC_MESSAGES
pt
LC_MESSAGES
pt_br
LC_MESSAGES
ru
LC_MESSAGES
sl
LC_MESSAGES
sv
LC_MESSAGES
th
LC_MESSAGES
tr
LC_MESSAGES
vi
LC_MESSAGES
zh
LC_MESSAGES
zh_Hans
LC_MESSAGES
order
part
plugin
report
stock
templates
users
ProcfileREADME.md
contrib
install.sh
installer
packager.io
docker
docs
requirements-dev.txtrequirements.inrequirements.txttasks.py

1
.github/FUNDING.yml vendored

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

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

@@ -29,9 +29,6 @@ jobs:
# Build the docker image # Build the docker image
build: build:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}
cancel-in-progress: true
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -81,6 +78,7 @@ jobs:
run: | run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> docker.dev.env 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 --disable-pty
docker-compose run inventree-dev-server invoke test --migrations --disable-pty
docker-compose down docker-compose down
- name: Set up QEMU - name: Set up QEMU
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -123,6 +121,8 @@ jobs:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
sbom: true
provenance: false
target: production target: production
tags: ${{ env.docker_tags }} tags: ${{ env.docker_tags }}
build-args: | build-args: |

@@ -28,6 +28,7 @@ jobs:
outputs: outputs:
server: ${{ steps.filter.outputs.server }} server: ${{ steps.filter.outputs.server }}
migrations: ${{ steps.filter.outputs.migrations }}
steps: steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0 - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
@@ -39,6 +40,9 @@ jobs:
- 'InvenTree/**' - 'InvenTree/**'
- 'requirements.txt' - 'requirements.txt'
- 'requirements-dev.txt' - 'requirements-dev.txt'
migrations:
- '**/migrations/**'
- '.github/workflows**'
pep_style: pep_style:
name: Style [Python] name: Style [Python]
@@ -99,7 +103,7 @@ jobs:
python3 ci/version_check.py python3 ci/version_check.py
mkdocs: mkdocs:
name: Style [documentation] name: Style [Documentation]
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: paths-filter needs: paths-filter
@@ -204,7 +208,7 @@ jobs:
- name: Check Migration Files - name: Check Migration Files
run: python3 ci/check_migration_files.py run: python3 ci/check_migration_files.py
- name: Coverage Tests - name: Coverage Tests
run: invoke coverage run: invoke test --coverage
- name: Upload Coverage Report - name: Upload Coverage Report
uses: coverallsapp/github-action@v2 uses: coverallsapp/github-action@v2
with: with:
@@ -297,3 +301,89 @@ jobs:
run: invoke test run: invoke test
- name: Data Export Test - name: Data Export Test
uses: ./.github/actions/migration 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
- name: 0.12.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.12.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate

@@ -15,6 +15,8 @@ env:
- INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt - INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
- INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml - INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
after_install: contrib/packager.io/postinstall.sh after_install: contrib/packager.io/postinstall.sh
before:
- contrib/packager.io/before.sh
dependencies: dependencies:
- curl - curl
- python3 - python3
@@ -33,3 +35,4 @@ dependencies:
targets: targets:
ubuntu-20.04: true ubuntu-20.04: true
debian-11: true debian-11: true
debian-12: true

@@ -41,7 +41,7 @@ repos:
args: [requirements.in, -o, requirements.txt] args: [requirements.in, -o, requirements.txt]
files: ^requirements\.(in|txt)$ files: ^requirements\.(in|txt)$
- repo: https://github.com/Riverside-Healthcare/djLint - repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.29.0 rev: v1.30.2
hooks: hooks:
- id: djlint-django - id: djlint-django
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell

@@ -19,7 +19,7 @@ pip install invoke && invoke setup-dev --tests
```bash ```bash
git clone https://github.com/inventree/InvenTree.git && cd InvenTree 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 install
docker compose run inventree-dev-server invoke setup-test docker compose run inventree-dev-server invoke setup-test --dev
docker compose up -d docker compose up -d
``` ```
@@ -33,7 +33,7 @@ Run the following command to set up all toolsets for development.
invoke setup-dev 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 the style 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 errors.*
## Branches and Versioning ## Branches and Versioning
@@ -135,10 +135,27 @@ To run only partial tests, for example for a module use:
invoke test --runtest order invoke test --runtest order
``` ```
To see all the available options:
```
invoke test --help
```
## Code Style ## 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. Code style is automatically checked as part of the project's CI pipeline on GitHub. This means that any pull requests which do not conform to the style guidelines will fail CI checks.
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`.
### Backend Code
Backend code (Python) is checked against the [PEP style guidelines](https://peps.python.org/pep-0008/). 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.
### Frontend Code
Frontend code (Javascript) is checked using [eslint](https://eslint.org/). While docstrings are not enforced for front-end code, good code documentation is encouraged!
### Running Checks Locally
If you have followed the setup devtools procedure, then code style checking is performend automatically whenever you commit changes to the code.
### Django templates ### Django templates
@@ -146,7 +163,7 @@ Django are checked by [djlint](https://github.com/Riverside-Healthcare/djlint) t
The following rules out of the [default set](https://djlint.com/docs/linter/) are not applied: The following rules out of the [default set](https://djlint.com/docs/linter/) are not applied:
```bash ```bash
D018: (Django) Internal links should use the {% url ... %} pattern D018: (Django) Internal links should use the { % url ... % } pattern
H006: Img tag should have height and width attributes H006: Img tag should have height and width attributes
H008: Attributes should be double quoted H008: Attributes should be double quoted
H021: Inline styles should be avoided H021: Inline styles should be avoided
@@ -187,16 +204,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 and javascript files are passed through the django templating engine. Translatable strings are implemented as follows:
```html ```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 ## Github use
### Tags ### Tags
The tags describe issues and PRs in multiple areas: The tags describe issues and PRs in multiple areas:
| Area | Name | Description | | Area | Name | Description |
|---|---|---| | --- | --- | --- |
| Triage Labels | | | | Triage Labels | | |
| | triage:not-checked | Item was not checked by the core team | | | triage:not-checked | Item was not checked by the core team |
| | triage:not-approved | Item is not green-light by maintainer | | | triage:not-approved | Item is not green-light by maintainer |

@@ -9,7 +9,7 @@
# - Runs InvenTree web server under django development server # - Runs InvenTree web server under django development server
# - Monitors source files for any changes, and live-reloads server # - Monitors source files for any changes, and live-reloads server
FROM python:3.9-slim as inventree_base FROM python:3.10-alpine3.18 as inventree_base
# Build arguments for this image # Build arguments for this image
ARG commit_hash="" ARG commit_hash=""
@@ -17,6 +17,8 @@ ARG commit_date=""
ARG commit_tag="" ARG commit_tag=""
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
ENV INVOKE_RUN_SHELL="/bin/ash"
ENV INVENTREE_LOG_LEVEL="WARNING" ENV INVENTREE_LOG_LEVEL="WARNING"
ENV INVENTREE_DOCKER="true" ENV INVENTREE_DOCKER="true"
@@ -51,44 +53,54 @@ LABEL org.label-schema.schema-version="1.0" \
org.label-schema.vcs-url="https://github.com/inventree/InvenTree.git" \ org.label-schema.vcs-url="https://github.com/inventree/InvenTree.git" \
org.label-schema.vcs-ref=${commit_tag} org.label-schema.vcs-ref=${commit_tag}
# RUN apt-get upgrade && apt-get update RUN apk add --no-cache \
RUN apt-get update git gettext py-cryptography \
# Install required system packages
RUN apt-get install -y --no-install-recommends \
git gcc g++ gettext gnupg libffi-dev libssl-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 # Image format support
libjpeg-dev webp libwebp-dev \ libjpeg libwebp zlib \
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#alpine-3-12
py3-pip py3-pillow py3-cffi py3-brotli pango poppler-utils \
# SQLite support # SQLite support
sqlite3 \ sqlite \
# PostgreSQL support # PostgreSQL support
libpq-dev postgresql-client \ postgresql-libs postgresql-client \
# MySQL / MariaDB support # MySQL / MariaDB support
default-libmysqlclient-dev mariadb-client && \ mariadb-connector-c-dev mariadb-client && \
apt-get autoclean && apt-get autoremove # fonts
apk --update --upgrade --no-cache add fontconfig ttf-freefont font-noto terminus-font && fc-cache -f
# Update pip EXPOSE 8000
RUN pip install --upgrade pip
# For ARMv7 architecture, add the pinwheels repo (for cryptography library) RUN mkdir -p ${INVENTREE_HOME}
WORKDIR ${INVENTREE_HOME}
COPY ./docker/requirements.txt base_requirements.txt
COPY ./requirements.txt ./
# For ARMv7 architecture, add the piwheels repo (for cryptography library)
# Otherwise, we have to build from source, which is difficult # Otherwise, we have to build from source, which is difficult
# Ref: https://github.com/inventree/InvenTree/pull/4598 # Ref: https://github.com/inventree/InvenTree/pull/4598
RUN \ RUN if [ `apk --print-arch` = "armv7" ]; then \
if [ `dpkg --print-architecture` = "armhf" ]; then \ printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \ fi
fi
# Install required base-level python packages RUN apk add --no-cache --virtual .build-deps \
COPY ./docker/requirements.txt base_requirements.txt gcc g++ musl-dev openssl-dev libffi-dev cargo python3-dev \
RUN pip install --disable-pip-version-check -U -r base_requirements.txt # Image format dev libs
jpeg-dev openjpeg-dev libwebp-dev zlib-dev \
# DB specific dev libs
postgresql-dev sqlite-dev mariadb-dev && \
pip install -r base_requirements.txt -r requirements.txt --no-cache-dir && \
apk --purge del .build-deps
COPY tasks.py docker/gunicorn.conf.py docker/init.sh ./
RUN chmod +x init.sh
ENTRYPOINT ["/bin/sh", "./init.sh"]
# InvenTree production image: # InvenTree production image:
# - Copies required files from local directory # - Copies required files from local directory
# - Installs required python packages from requirements.txt
# - Starts a gunicorn webserver # - Starts a gunicorn webserver
FROM inventree_base as production FROM inventree_base as production
ENV INVENTREE_DEBUG=False ENV INVENTREE_DEBUG=False
@@ -98,33 +110,14 @@ ENV INVENTREE_COMMIT_HASH="${commit_hash}"
ENV INVENTREE_COMMIT_DATE="${commit_date}" ENV INVENTREE_COMMIT_DATE="${commit_date}"
# Copy source code # Copy source code
COPY InvenTree ${INVENTREE_HOME}/InvenTree COPY InvenTree ./InvenTree
# Copy other key files
COPY requirements.txt ${INVENTREE_HOME}/requirements.txt
COPY tasks.py ${INVENTREE_HOME}/tasks.py
COPY docker/gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
COPY docker/init.sh ${INVENTREE_MNG_DIR}/init.sh
# Need to be running from within this directory
WORKDIR ${INVENTREE_MNG_DIR}
# Drop to the inventree user for the production image
#RUN adduser inventree
#RUN chown -R inventree:inventree ${INVENTREE_HOME}
#USER inventree
# Install InvenTree packages
RUN pip3 install --user --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
# Server init entrypoint
ENTRYPOINT ["/bin/bash", "./init.sh"]
# Launch the production server # Launch the production server
# TODO: Work out why environment variables cannot be interpolated in this command # TODO: Work out why environment variables cannot be interpolated in this command
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here # TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
FROM inventree_base as dev FROM inventree_base as dev
# The development image requires the source code to be mounted to /home/inventree/ # The development image requires the source code to be mounted to /home/inventree/
@@ -139,7 +132,7 @@ ENV INVENTREE_PY_ENV="${INVENTREE_DATA_DIR}/env"
WORKDIR ${INVENTREE_HOME} WORKDIR ${INVENTREE_HOME}
# Entrypoint ensures that we are running in the python virtual environment # Entrypoint ensures that we are running in the python virtual environment
ENTRYPOINT ["/bin/bash", "./docker/init.sh"] ENTRYPOINT ["/bin/ash", "./docker/init.sh"]
# Launch the development server # Launch the development server
CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"] CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"]

@@ -306,47 +306,6 @@ class APISearchView(APIView):
return Response(results) 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): class MetadataView(RetrieveUpdateAPI):
"""Generic API endpoint for reading and editing metadata for a model""" """Generic API endpoint for reading and editing metadata for a model"""

@@ -2,14 +2,44 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 119 INVENTREE_API_VERSION = 129
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v129 -> 2023-07-06 : https://github.com/inventree/InvenTree/pull/5189
- Changes 'serial_lte' and 'serial_gte' stock filters to point to 'serial_int' field
v128 -> 2023-07-06 : https://github.com/inventree/InvenTree/pull/5186
- Adds 'available' filter for BuildLine API endpoint
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 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, Part relateds, Stock item test result - 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 v118 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4935
- Adds extra fields for the PartParameterTemplate model - Adds extra fields for the PartParameterTemplate model
@@ -27,6 +57,7 @@ v115 - > 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846
v114 -> 2023-05-16 : https://github.com/inventree/InvenTree/pull/4825 v114 -> 2023-05-16 : https://github.com/inventree/InvenTree/pull/4825
- Adds "delivery_date" to shipments - Adds "delivery_date" to shipments
>>>>>>> inventree/master
v113 -> 2023-05-13 : https://github.com/inventree/InvenTree/pull/4800 v113 -> 2023-05-13 : https://github.com/inventree/InvenTree/pull/4800
- Adds API endpoints for scrapping a build output - Adds API endpoints for scrapping a build output

@@ -53,7 +53,7 @@ class InvenTreeConfig(AppConfig):
self.collect_notification_methods() self.collect_notification_methods()
# Ensure the unit registry is loaded # Ensure the unit registry is loaded
InvenTree.conversion.reload_unit_registry() InvenTree.conversion.get_unit_registry()
if canAppAccessDatabase() or settings.TESTING_ENV: if canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup() self.add_user_on_startup()
@@ -131,19 +131,22 @@ class InvenTreeConfig(AppConfig):
update = False update = False
try: try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange') backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
last_update = backend.last_update if backend.exists():
backend = backend.first()
if last_update is None: last_update = backend.last_update
# Never been updated
logger.info("Exchange backend has never been updated")
update = True
# Backend currency has changed? if last_update is None:
if base_currency != backend.base_currency: # Never been updated
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}") logger.info("Exchange backend has never been updated")
update = True 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): except (ExchangeBackend.DoesNotExist):
logger.info("Exchange backend not found - updating") logger.info("Exchange backend not found - updating")
@@ -197,8 +200,8 @@ class InvenTreeConfig(AppConfig):
else: else:
new_user = user.objects.create_superuser(add_user, add_email, add_password) new_user = user.objects.create_superuser(add_user, add_email, add_password)
logger.info(f'User {str(new_user)} was created!') logger.info(f'User {str(new_user)} was created!')
except IntegrityError as _e: except IntegrityError:
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') logger.warning(f'The user "{add_user}" could not be created')
# do not try again # do not try again
settings.USER_ADDED = True settings.USER_ADDED = True

@@ -4,10 +4,8 @@
import InvenTree.email import InvenTree.email
import InvenTree.status import InvenTree.status
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, from generic.states import StatusCode
ReturnOrderLineStatus, ReturnOrderStatus, from InvenTree.helpers import inheritors
SalesOrderStatus, StockHistoryCode,
StockStatus)
from users.models import RuleSet, check_user_role from users.models import RuleSet, check_user_role
@@ -57,17 +55,7 @@ def status_codes(request):
return {} return {}
request._inventree_status_codes = True 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): def user_roles(request):

@@ -1,5 +1,7 @@
"""Helper functions for converting between units.""" """Helper functions for converting between units."""
import logging
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -8,6 +10,9 @@ import pint
_unit_registry = None _unit_registry = None
logger = logging.getLogger('inventree')
def get_unit_registry(): def get_unit_registry():
"""Return a custom instance of the Pint UnitRegistry.""" """Return a custom instance of the Pint UnitRegistry."""
@@ -26,6 +31,9 @@ def reload_unit_registry():
This function is called at startup, and whenever the database is updated. This function is called at startup, and whenever the database is updated.
""" """
import time
t_start = time.time()
global _unit_registry global _unit_registry
_unit_registry = pint.UnitRegistry() _unit_registry = pint.UnitRegistry()
@@ -39,6 +47,9 @@ def reload_unit_registry():
# TODO: Allow for custom units to be defined in the database # 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): def convert_physical_value(value: str, unit: str = None):
"""Validate that the provided value is a valid physical quantity. """Validate that the provided value is a valid physical quantity.
@@ -80,7 +91,7 @@ def convert_physical_value(value: str, unit: str = None):
# At this point we *should* have a valid pint value # At this point we *should* have a valid pint value
# To double check, look at the maginitude # To double check, look at the maginitude
float(val.magnitude) float(val.magnitude)
except (TypeError, ValueError): except (TypeError, ValueError, AttributeError):
error = _('Provided value is not a valid number') error = _('Provided value is not a valid number')
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError): except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
error = _('Provided value has an invalid unit') error = _('Provided value has an invalid unit')

@@ -21,6 +21,8 @@ from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
PrependedText) PrependedText)
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Field, Layout 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 common.models import InvenTreeSetting
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
@@ -206,6 +208,11 @@ class CustomSignupForm(SignupForm):
return cleaned_data 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: class RegistratonMixin:
"""Mixin to check if registration should be enabled.""" """Mixin to check if registration should be enabled."""
@@ -214,7 +221,7 @@ class RegistratonMixin:
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, default: `LOGIN_ENABLE_REG`.
""" """
if settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG')): if registration_enabled():
return super().is_open_for_signup(request, *args, **kwargs) return super().is_open_for_signup(request, *args, **kwargs)
return False return False
@@ -319,3 +326,20 @@ class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocial
# Otherwise defer to the original allauth adapter. # Otherwise defer to the original allauth adapter.
return super().login(request, user) 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.'))

@@ -262,32 +262,49 @@ def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNo
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder. content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
exclude (User, optional): User instance that should be excluded. Defaults to None. exclude (User, optional): User instance that should be excluded. Defaults to None.
""" """
if instance.responsible is not None: notify_users([instance.responsible], instance, sender, content=content, exclude=exclude)
# 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': {
'html': content.template.format(**content_context),
'subject': content.name.format(**content_context),
}
}
# Create notification def notify_users(users, instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None):
trigger_notification( """Notify all passed users or groups.
instance,
content.slug.format(**content_context), Parses the supplied content with the provided instance and sender and sends a notification to all users,
targets=[instance.responsible], excluding the optional excluded list.
target_exclude=[exclude],
context=context, 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,
)

@@ -0,0 +1,75 @@
"""Functions for magic login."""
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import sesame.utils
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.views import APIView
def send_simple_login_email(user, link):
"""Send an email with the login link to this user."""
site = Site.objects.get_current()
context = {
"username": user.username,
"site_name": site.name,
"link": link,
}
email_plaintext_message = render_to_string("InvenTree/user_simple_login.txt", context)
send_mail(
_(f"[{site.name}] Log in to the app"),
email_plaintext_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
)
class GetSimpleLoginSerializer(serializers.Serializer):
"""Serializer for the simple login view."""
email = serializers.CharField(label=_("Email"))
class GetSimpleLoginView(APIView):
"""View to send a simple login link."""
permission_classes = ()
serializer_class = GetSimpleLoginSerializer
def post(self, request, *args, **kwargs):
"""Get the token for the current user or fail."""
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
self.email_submitted(email=serializer.data["email"])
return Response({"status": "ok"})
def email_submitted(self, email):
"""Notify user about link."""
user = self.get_user(email)
if user is None:
print("user not found:", email)
return
link = self.create_link(user)
send_simple_login_email(user, link)
def get_user(self, email):
"""Find the user with this email address."""
try:
return User.objects.get(email=email)
except User.DoesNotExist:
return None
def create_link(self, user):
"""Create a login link for this user."""
link = reverse("sesame-login")
link = self.request.build_absolute_uri(link)
link += sesame.utils.get_query_string(user)
return link

@@ -18,7 +18,7 @@ class Command(BaseCommand):
while not connected: while not connected:
time.sleep(5) time.sleep(2)
try: try:
connection.ensure_connection() connection.ensure_connection()

@@ -14,7 +14,6 @@ from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
from error_report.middleware import ExceptionProcessor from error_report.middleware import ExceptionProcessor
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from common.models import InvenTreeSetting
from InvenTree.urls import frontendpatterns from InvenTree.urls import frontendpatterns
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@@ -65,6 +64,9 @@ class AuthRequiredMiddleware(object):
elif request.path_info.startswith('/accounts/'): elif request.path_info.startswith('/accounts/'):
authorized = True authorized = True
elif request.path_info.startswith('/platform/') or request.path_info == '/platform':
authorized = True
elif 'Authorization' in request.headers.keys() or 'authorization' in request.headers.keys(): elif 'Authorization' in request.headers.keys() or 'authorization' in request.headers.keys():
auth = request.headers.get('Authorization', request.headers.get('authorization')).strip() auth = request.headers.get('Authorization', request.headers.get('authorization')).strip()
@@ -123,6 +125,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
"""Check if user is required to have MFA enabled.""" """Check if user is required to have MFA enabled."""
def require_2fa(self, request): def require_2fa(self, request):
"""Use setting to check if MFA should be enforced for frontend page.""" """Use setting to check if MFA should be enforced for frontend page."""
from common.models import InvenTreeSetting
try: try:
if url_matcher.resolve(request.path[1:]): if url_matcher.resolve(request.path[1:]):
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA') return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')
@@ -165,4 +170,31 @@ class InvenTreeExceptionProcessor(ExceptionProcessor):
if kind in settings.IGNORED_ERRORS: if kind in settings.IGNORED_ERRORS:
return return
return super().process_exception(request, exception) 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()

@@ -14,6 +14,11 @@ def isImportingData():
return 'loaddata' in sys.argv 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 isInMainThread(): def isInMainThread():
"""Django starts two processes, one for the actual dev server and the other to reload the application. """Django starts two processes, one for the actual dev server and the other to reload the application.

@@ -21,7 +21,7 @@ from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer from taggit.serializers import TaggitSerializer
from common.models import InvenTreeSetting import common.models as common_models
from common.settings import currency_code_default, currency_code_mappings from common.settings import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers_model import download_image_from_url from InvenTree.helpers_model import download_image_from_url
@@ -724,7 +724,7 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
if not url: if not url:
return return
if not InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL'): if not common_models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL'):
raise ValidationError(_("Downloading images from remote URL is not enabled")) raise ValidationError(_("Downloading images from remote URL is not enabled"))
try: try:

@@ -22,6 +22,7 @@ from django.http import Http404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import moneyed import moneyed
from dotenv import load_dotenv
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
from InvenTree.sentry import default_sentry_dsn, init_sentry from InvenTree.sentry import default_sentry_dsn, init_sentry
@@ -65,6 +66,12 @@ BASE_DIR = config.get_base_dir()
# Load configuration data # Load configuration data
CONFIG = config.load_config_data(set_cache=True) 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 # Default action is to run the system in Debug mode
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', True) DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', True)
@@ -196,6 +203,7 @@ INSTALLED_APPS = [
'stock.apps.StockConfig', 'stock.apps.StockConfig',
'users.apps.UsersConfig', 'users.apps.UsersConfig',
'plugin.apps.PluginAppConfig', 'plugin.apps.PluginAppConfig',
'generic',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last 'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
# Core django modules # Core django modules
@@ -226,6 +234,7 @@ INSTALLED_APPS = [
'formtools', # Form wizard tools 'formtools', # Form wizard tools
'dbbackup', # Backups - django-dbbackup 'dbbackup', # Backups - django-dbbackup
'taggit', # Tagging 'taggit', # Tagging
'flags', # Flagging - django-flags
'allauth', # Base app for SSO 'allauth', # Base app for SSO
'allauth.account', # Extend user with accounts 'allauth.account', # Extend user with accounts
@@ -236,6 +245,8 @@ INSTALLED_APPS = [
'django_otp.plugins.otp_static', # Backup codes 'django_otp.plugins.otp_static', # Backup codes
'allauth_2fa', # MFA flow for allauth '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 'drf_spectacular', # API documentation
'django_ical', # For exporting calendars 'django_ical', # For exporting calendars
@@ -265,6 +276,7 @@ AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
'django.contrib.auth.backends.RemoteUserBackend', # proxy login 'django.contrib.auth.backends.RemoteUserBackend', # proxy login
'django.contrib.auth.backends.ModelBackend', 'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers 'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
"sesame.backends.ModelBackend", # Magic link login django-sesame
]) ])
DEBUG_TOOLBAR_ENABLED = DEBUG and get_setting('INVENTREE_DEBUG_TOOLBAR', 'debug_toolbar', False) DEBUG_TOOLBAR_ENABLED = DEBUG and get_setting('INVENTREE_DEBUG_TOOLBAR', 'debug_toolbar', False)
@@ -371,6 +383,23 @@ if DEBUG:
# Enable browsable API if in DEBUG mode # Enable browsable API if in DEBUG mode
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer') 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 = { SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API', 'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system', 'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
@@ -573,6 +602,10 @@ DATABASES = {
REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False) 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') REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER')
# Magic login django-sesame
SESAME_MAX_AGE = 300
LOGIN_REDIRECT_URL = "/platform/logged-in/"
# sentry.io integration for error reporting # sentry.io integration for error reporting
SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False) SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False)
@@ -840,6 +873,8 @@ ACCOUNT_LOGIN_ATTEMPTS_LIMIT = get_setting('INVENTREE_LOGIN_ATTEMPTS', 'login_at
ACCOUNT_DEFAULT_HTTP_PROTOCOL = get_setting('INVENTREE_LOGIN_DEFAULT_HTTP_PROTOCOL', 'login_default_protocol', 'http') ACCOUNT_DEFAULT_HTTP_PROTOCOL = get_setting('INVENTREE_LOGIN_DEFAULT_HTTP_PROTOCOL', 'login_default_protocol', 'http')
ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
ACCOUNT_PREVENT_ENUMERATION = True ACCOUNT_PREVENT_ENUMERATION = True
# 2FA
REMOVE_SUCCESS_URL = 'settings'
# override forms / adapters # override forms / adapters
ACCOUNT_FORMS = { ACCOUNT_FORMS = {
@@ -936,3 +971,23 @@ if DEBUG:
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'") logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.info(f"STATIC_ROOT: '{STATIC_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)

@@ -0,0 +1,127 @@
"""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)

@@ -223,8 +223,7 @@ main {
} }
.sub-table { .sub-table {
margin-left: 45px; margin-left: 60px;
margin-right: 45px;
} }
.detail-icon .glyphicon { .detail-icon .glyphicon {
@@ -270,10 +269,6 @@ main {
} }
/* Styles for table buttons and filtering */ /* Styles for table buttons and filtering */
.button-toolbar .btn {
margin-left: 1px;
margin-right: 1px;
}
.filter-list { .filter-list {
display: inline-block; display: inline-block;

@@ -38,7 +38,14 @@ def is_worker_running(**kwargs):
) )
# If any results are returned, then the background worker is running! # If any results are returned, then the background worker is running!
return results.exists() 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 result
def check_system_health(**kwargs): def check_system_health(**kwargs):

@@ -2,377 +2,161 @@
from django.utils.translation import gettext_lazy as _ 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): class PurchaseOrderStatus(StatusCode):
"""Defines a set of status codes for a PurchaseOrder.""" """Defines a set of status codes for a PurchaseOrder."""
# Order status codes # Order status codes
PENDING = 10 # Order is pending (not yet placed) PENDING = 10, _("Pending"), 'secondary' # Order is pending (not yet placed)
PLACED = 20 # Order has been placed with supplier PLACED = 20, _("Placed"), 'primary' # Order has been placed with supplier
COMPLETE = 30 # Order has been completed COMPLETE = 30, _("Complete"), 'success' # Order has been completed
CANCELLED = 40 # Order was cancelled CANCELLED = 40, _("Cancelled"), 'danger' # Order was cancelled
LOST = 50 # Order was lost LOST = 50, _("Lost"), 'warning' # Order was lost
RETURNED = 60 # Order was returned RETURNED = 60, _("Returned"), 'warning' # Order was returned
options = {
PENDING: _("Pending"),
PLACED: _("Placed"),
COMPLETE: _("Complete"),
CANCELLED: _("Cancelled"),
LOST: _("Lost"),
RETURNED: _("Returned"),
}
colors = { class PurchaseOrderStatusGroups:
PENDING: 'secondary', """Groups for PurchaseOrderStatus codes."""
PLACED: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
LOST: 'warning',
RETURNED: 'warning',
}
# Open orders # Open orders
OPEN = [ OPEN = [
PENDING, PurchaseOrderStatus.PENDING.value,
PLACED, PurchaseOrderStatus.PLACED.value,
] ]
# Failed orders # Failed orders
FAILED = [ FAILED = [
CANCELLED, PurchaseOrderStatus.CANCELLED.value,
LOST, PurchaseOrderStatus.LOST.value,
RETURNED PurchaseOrderStatus.RETURNED.value
] ]
class SalesOrderStatus(StatusCode): class SalesOrderStatus(StatusCode):
"""Defines a set of status codes for a SalesOrder.""" """Defines a set of status codes for a SalesOrder."""
PENDING = 10 # Order is pending PENDING = 10, _("Pending"), 'secondary' # Order is pending
IN_PROGRESS = 15 # Order has been issued, and is in progress IN_PROGRESS = 15, _("In Progress"), 'primary' # Order has been issued, and is in progress
SHIPPED = 20 # Order has been shipped to customer SHIPPED = 20, _("Shipped"), 'success' # Order has been shipped to customer
CANCELLED = 40 # Order has been cancelled CANCELLED = 40, _("Cancelled"), 'danger' # Order has been cancelled
LOST = 50 # Order was lost LOST = 50, _("Lost"), 'warning' # Order was lost
RETURNED = 60 # Order was returned RETURNED = 60, _("Returned"), 'warning' # Order was returned
options = {
PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
SHIPPED: _("Shipped"),
CANCELLED: _("Cancelled"),
LOST: _("Lost"),
RETURNED: _("Returned"),
}
colors = { class SalesOrderStatusGroups:
PENDING: 'secondary', """Groups for SalesOrderStatus codes."""
IN_PROGRESS: 'primary',
SHIPPED: 'success',
CANCELLED: 'danger',
LOST: 'warning',
RETURNED: 'warning',
}
# Open orders # Open orders
OPEN = [ OPEN = [
PENDING, SalesOrderStatus.PENDING.value,
IN_PROGRESS, SalesOrderStatus.IN_PROGRESS.value,
] ]
# Completed orders # Completed orders
COMPLETE = [ COMPLETE = [
SHIPPED, SalesOrderStatus.SHIPPED.value,
] ]
class StockStatus(StatusCode): class StockStatus(StatusCode):
"""Status codes for Stock.""" """Status codes for Stock."""
OK = 10 # Item is OK OK = 10, _("OK"), 'success' # Item is OK
ATTENTION = 50 # Item requires attention ATTENTION = 50, _("Attention needed"), 'warning' # Item requires attention
DAMAGED = 55 # Item is damaged DAMAGED = 55, _("Damaged"), 'warning' # Item is damaged
DESTROYED = 60 # Item is destroyed DESTROYED = 60, _("Destroyed"), 'danger' # Item is destroyed
REJECTED = 65 # Item is rejected REJECTED = 65, _("Rejected"), 'danger' # Item is rejected
LOST = 70 # Item has been lost LOST = 70, _("Lost"), 'dark' # Item has been lost
QUARANTINED = 75 # Item has been quarantined and is unavailable QUARANTINED = 75, _("Quarantined"), 'info' # Item has been quarantined and is unavailable
RETURNED = 85 # Item has been returned from a customer RETURNED = 85, _("Returned"), 'warning' # Item has been returned from a customer
options = {
OK: _("OK"),
ATTENTION: _("Attention needed"),
DAMAGED: _("Damaged"),
DESTROYED: _("Destroyed"),
LOST: _("Lost"),
REJECTED: _("Rejected"),
QUARANTINED: _("Quarantined"),
}
colors = { class StockStatusGroups:
OK: 'success', """Groups for StockStatus codes."""
ATTENTION: 'warning',
DAMAGED: 'warning',
DESTROYED: 'danger',
LOST: 'dark',
REJECTED: 'danger',
QUARANTINED: 'info'
}
# The following codes correspond to parts that are 'available' or 'in stock' # The following codes correspond to parts that are 'available' or 'in stock'
AVAILABLE_CODES = [ AVAILABLE_CODES = [
OK, StockStatus.OK.value,
ATTENTION, StockStatus.ATTENTION.value,
DAMAGED, StockStatus.DAMAGED.value,
RETURNED, StockStatus.RETURNED.value,
] ]
class StockHistoryCode(StatusCode): class StockHistoryCode(StatusCode):
"""Status codes for StockHistory.""" """Status codes for StockHistory."""
LEGACY = 0 LEGACY = 0, _('Legacy stock tracking entry')
CREATED = 1 CREATED = 1, _('Stock item created')
# Manual editing operations # Manual editing operations
EDITED = 5 EDITED = 5, _('Edited stock item')
ASSIGNED_SERIAL = 6 ASSIGNED_SERIAL = 6, _('Assigned serial number')
# Manual stock operations # Manual stock operations
STOCK_COUNT = 10 STOCK_COUNT = 10, _('Stock counted')
STOCK_ADD = 11 STOCK_ADD = 11, _('Stock manually added')
STOCK_REMOVE = 12 STOCK_REMOVE = 12, _('Stock manually removed')
# Location operations # Location operations
STOCK_MOVE = 20 STOCK_MOVE = 20, _('Location changed')
STOCK_UPDATE = 25 STOCK_UPDATE = 25, _('Stock updated')
# Installation operations # Installation operations
INSTALLED_INTO_ASSEMBLY = 30 INSTALLED_INTO_ASSEMBLY = 30, _('Installed into assembly')
REMOVED_FROM_ASSEMBLY = 31 REMOVED_FROM_ASSEMBLY = 31, _('Removed from assembly')
INSTALLED_CHILD_ITEM = 35 INSTALLED_CHILD_ITEM = 35, _('Installed component item')
REMOVED_CHILD_ITEM = 36 REMOVED_CHILD_ITEM = 36, _('Removed component item')
# Stock splitting operations # Stock splitting operations
SPLIT_FROM_PARENT = 40 SPLIT_FROM_PARENT = 40, _('Split from parent item')
SPLIT_CHILD_ITEM = 42 SPLIT_CHILD_ITEM = 42, _('Split child item')
# Stock merging operations # Stock merging operations
MERGED_STOCK_ITEMS = 45 MERGED_STOCK_ITEMS = 45, _('Merged stock items')
# Convert stock item to variant # Convert stock item to variant
CONVERTED_TO_VARIANT = 48 CONVERTED_TO_VARIANT = 48, _('Converted to variant')
# Build order codes # Build order codes
BUILD_OUTPUT_CREATED = 50 BUILD_OUTPUT_CREATED = 50, _('Build order output created')
BUILD_OUTPUT_COMPLETED = 55 BUILD_OUTPUT_COMPLETED = 55, _('Build order output completed')
BUILD_OUTPUT_REJECTED = 56 BUILD_OUTPUT_REJECTED = 56, _('Build order output rejected')
BUILD_CONSUMED = 57 BUILD_CONSUMED = 57, _('Consumed by build order')
# Sales order codes # Sales order codes
SHIPPED_AGAINST_SALES_ORDER = 60 SHIPPED_AGAINST_SALES_ORDER = 60, _("Shipped against Sales Order")
# Purchase order codes # Purchase order codes
RECEIVED_AGAINST_PURCHASE_ORDER = 70 RECEIVED_AGAINST_PURCHASE_ORDER = 70, _('Received against Purchase Order')
# Return order codes # Return order codes
RETURNED_AGAINST_RETURN_ORDER = 80 RETURNED_AGAINST_RETURN_ORDER = 80, _('Returned against Return Order')
# Customer actions # Customer actions
SENT_TO_CUSTOMER = 100 SENT_TO_CUSTOMER = 100, _('Sent to customer')
RETURNED_FROM_CUSTOMER = 105 RETURNED_FROM_CUSTOMER = 105, _('Returned from customer')
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'),
STOCK_UPDATE: _('Stock updated'),
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_OUTPUT_REJECTED: _('Build order output rejected'),
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): class BuildStatus(StatusCode):
"""Build status codes.""" """Build status codes."""
PENDING = 10 # Build is pending / active PENDING = 10, _("Pending"), 'secondary' # Build is pending / active
PRODUCTION = 20 # BuildOrder is in production PRODUCTION = 20, _("Production"), 'primary' # BuildOrder is in production
CANCELLED = 30 # Build was cancelled CANCELLED = 30, _("Cancelled"), 'danger' # Build was cancelled
COMPLETE = 40 # Build is complete COMPLETE = 40, _("Complete"), 'success' # Build is complete
options = {
PENDING: _("Pending"),
PRODUCTION: _("Production"),
CANCELLED: _("Cancelled"),
COMPLETE: _("Complete"),
}
colors = { class BuildStatusGroups:
PENDING: 'secondary', """Groups for BuildStatus codes."""
PRODUCTION: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
}
ACTIVE_CODES = [ ACTIVE_CODES = [
PENDING, BuildStatus.PENDING.value,
PRODUCTION, BuildStatus.PRODUCTION.value,
] ]
@@ -380,68 +164,40 @@ class ReturnOrderStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrder""" """Defines a set of status codes for a ReturnOrder"""
# Order is pending, waiting for receipt of items # Order is pending, waiting for receipt of items
PENDING = 10 PENDING = 10, _("Pending"), 'secondary'
# Items have been received, and are being inspected # Items have been received, and are being inspected
IN_PROGRESS = 20 IN_PROGRESS = 20, _("In Progress"), 'primary'
COMPLETE = 30 COMPLETE = 30, _("Complete"), 'success'
CANCELLED = 40 CANCELLED = 40, _("Cancelled"), 'danger'
class ReturnOrderStatusGroups:
"""Groups for ReturnOrderStatus codes."""
OPEN = [ OPEN = [
PENDING, ReturnOrderStatus.PENDING.value,
IN_PROGRESS, ReturnOrderStatus.IN_PROGRESS.value,
] ]
options = {
PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
COMPLETE: _("Complete"),
CANCELLED: _("Cancelled"),
}
colors = {
PENDING: 'secondary',
IN_PROGRESS: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
}
class ReturnOrderLineStatus(StatusCode): class ReturnOrderLineStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrderLineItem""" """Defines a set of status codes for a ReturnOrderLineItem"""
PENDING = 10 PENDING = 10, _("Pending"), 'secondary'
# Item is to be returned to customer, no other action # Item is to be returned to customer, no other action
RETURN = 20 RETURN = 20, _("Return"), 'success'
# Item is to be repaired, and returned to customer # Item is to be repaired, and returned to customer
REPAIR = 30 REPAIR = 30, _("Repair"), 'primary'
# Item is to be replaced (new item shipped) # Item is to be replaced (new item shipped)
REPLACE = 40 REPLACE = 40, _("Replace"), 'warning'
# Item is to be refunded (cannot be repaired) # Item is to be refunded (cannot be repaired)
REFUND = 50 REFUND = 50, _("Refund"), 'info'
# Item is rejected # Item is rejected
REJECT = 60 REJECT = 60, _("Reject"), 'danger'
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',
}

@@ -91,13 +91,15 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
""" """
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.ready import isInTestMode
if n_days <= 0: if n_days <= 0:
logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run") logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run")
return False return False
# Sleep a random number of seconds to prevent worker conflict # Sleep a random number of seconds to prevent worker conflict
time.sleep(random.randint(1, 5)) if not isInTestMode():
time.sleep(random.randint(1, 5))
attempt_key = f'_{task_name}_ATTEMPT' attempt_key = f'_{task_name}_ATTEMPT'
success_key = f'_{task_name}_SUCCESS' success_key = f'_{task_name}_SUCCESS'
@@ -186,6 +188,8 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
task.run() task.run()
except ImportError: except ImportError:
raise_warning(f"WARNING: '{taskname}' not started - Function not found") 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: else:
if callable(taskname): if callable(taskname):
@@ -495,7 +499,7 @@ def check_for_updates():
def update_exchange_rates(): def update_exchange_rates():
"""Update currency exchange rates.""" """Update currency exchange rates."""
try: try:
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import Rate
from common.settings import currency_code_default, currency_codes from common.settings import currency_code_default, currency_codes
from InvenTree.exchange import InvenTreeExchange from InvenTree.exchange import InvenTreeExchange
@@ -507,22 +511,9 @@ def update_exchange_rates():
# Other error? # Other error?
return 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() backend = InvenTreeExchange()
logger.info(f"Updating exchange rates from {backend.url}")
base = currency_code_default() base = currency_code_default()
logger.info(f"Updating exchange rates using base currency '{base}'")
logger.info(f"Using base currency '{base}'")
try: try:
backend.update_rates(base_currency=base) backend.update_rates(base_currency=base)

@@ -0,0 +1,8 @@
Hello {{username}},
You requested that we send you a link to log in to our app:
{{link}}
Regards,
{{site_name}}

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

@@ -11,12 +11,15 @@ import django.core.exceptions as django_exceptions
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core import mail
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money from djmoney.money import Money
from sesame.utils import get_user
import InvenTree.conversion import InvenTree.conversion
import InvenTree.format import InvenTree.format
@@ -56,6 +59,23 @@ class ConversionTest(TestCase):
q = InvenTree.conversion.convert_physical_value(val).to_base_units() q = InvenTree.conversion.convert_physical_value(val).to_base_units()
self.assertEqual(q.magnitude, expected) 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): class ValidatorTest(TestCase):
"""Simple tests for custom field validators.""" """Simple tests for custom field validators."""
@@ -1044,3 +1064,38 @@ class SanitizerTest(TestCase):
# Test that invalid string is cleanded # Test that invalid string is cleanded
self.assertNotEqual(dangerous_string, sanitize_svg(dangerous_string)) self.assertNotEqual(dangerous_string, sanitize_svg(dangerous_string))
class MagicLoginTest(InvenTreeTestCase):
"""Test magic login token generation."""
def test_generation(self):
"""Test that magic login tokens are generated correctly"""
# User does not exists
resp = self.client.post(reverse('sesame-generate'), {'email': 1})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {'status': 'ok'})
self.assertEqual(len(mail.outbox), 0)
# User exists
resp = self.client.post(reverse('sesame-generate'), {'email': self.user.email})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {'status': 'ok'})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, '[example.com] Log in to the app')
# Check that the token is in the email
self.assertTrue('http://testserver/api/email/login/' in mail.outbox[0].body)
token = mail.outbox[0].body.split('/')[-1].split('\n')[0][8:]
self.assertEqual(get_user(token), self.user)
# Log user off
self.client.logout()
# Check that the login works
resp = self.client.get(reverse('sesame-login') + '?sesame=' + token)
self.assertEqual(resp.status_code, 302)
self.assertEqual(resp.url, '/platform/logged-in/')
# And we should be logged in again
self.assertEqual(resp.wsgi_request.user, self.user)

@@ -2,13 +2,17 @@
import csv import csv
import io import io
import json
import re import re
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from django.db import connections
from django.http.response import StreamingHttpResponse from django.http.response import StreamingHttpResponse
from django.test import TestCase from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -233,9 +237,38 @@ class ExchangeRateMixin:
Rate.objects.bulk_create(items) Rate.objects.bulk_create(items)
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
"""Testcase with user setup buildin."""
pass
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Base class for running InvenTree API tests.""" """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): def checkResponse(self, url, method, expected_code, response):
"""Debug output for an unexpected response""" """Debug output for an unexpected response"""
@@ -408,8 +441,3 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
data.append(entry) data.append(entry)
return data return data
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
"""Testcase with user setup buildin."""
pass

@@ -7,9 +7,13 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from dj_rest_auth.registration.views import (SocialAccountDisconnectView,
SocialAccountListView)
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from sesame.views import LoginView
from build.api import build_api_urls from build.api import build_api_urls
from build.urls import build_urls from build.urls import build_urls
@@ -31,13 +35,15 @@ from stock.urls import stock_urls
from users.api import user_urls from users.api import user_urls
from .api import APISearchView, InfoView, NotFoundView from .api import APISearchView, InfoView, NotFoundView
from .magic_login import GetSimpleLoginView
from .social_auth_urls import SocialProvierListView, social_auth_urlpatterns
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView, from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
CustomEmailView, CustomLoginView, CustomEmailView, CustomLoginView,
CustomPasswordResetFromKeyView, CustomPasswordResetFromKeyView,
CustomSessionDeleteOtherView, CustomSessionDeleteView, CustomSessionDeleteOtherView, CustomSessionDeleteView,
CustomTwoFactorRemove, DatabaseStatsView, DynamicJsView, DatabaseStatsView, DynamicJsView, EditUserView, IndexView,
EditUserView, IndexView, NotificationsView, SearchView, NotificationsView, SearchView, SetPasswordView,
SetPasswordView, SettingsView, auth_request) SettingsView, auth_request)
admin.site.site_header = "InvenTree Admin" admin.site.site_header = "InvenTree Admin"
@@ -71,6 +77,18 @@ apipatterns = [
# InvenTree information endpoint # InvenTree information endpoint
path('', InfoView.as_view(), name='api-inventree-info'), path('', InfoView.as_view(), name='api-inventree-info'),
# Third party API endpoints
path('auth/', include('dj_rest_auth.urls')),
path('auth/registration/', include('dj_rest_auth.registration.urls')),
path('auth/providers/', SocialProvierListView.as_view(), name='social_providers'),
path('auth/social/', include(social_auth_urlpatterns)),
path('auth/social/', SocialAccountListView.as_view(), name='social_account_list'),
path('auth/social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
# Magic login URLs
path("email/generate/", csrf_exempt(GetSimpleLoginView().as_view()), name="sesame-generate",),
path("email/login/", LoginView.as_view(), name="sesame-login"),
# Unknown endpoint # Unknown endpoint
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'), re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
] ]
@@ -95,6 +113,7 @@ notifications_urls = [
dynamic_javascript_urls = [ dynamic_javascript_urls = [
re_path(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'), 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'^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'), re_path(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'),
] ]
@@ -110,6 +129,7 @@ translated_javascript_urls = [
re_path(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'), 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'^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'^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'^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'^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'), re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
@@ -178,10 +198,6 @@ frontendpatterns = [
re_path(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'), re_path(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'),
re_path(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"), re_path(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"),
# Temporary fix for django-allauth-2fa # TODO remove
# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
re_path(r'^accounts/two_factor/remove/?$', CustomTwoFactorRemove.as_view(), name='two-factor-remove'),
# Override login page # Override login page
re_path("accounts/login/", CustomLoginView.as_view(), name="account_login"), re_path("accounts/login/", CustomLoginView.as_view(), name="account_login"),

@@ -5,24 +5,26 @@ Provides information on the current InvenTree version
import os import os
import pathlib import pathlib
import platform
import re import re
from datetime import datetime as dt from datetime import datetime as dt
from datetime import timedelta as td from datetime import timedelta as td
import django import django
from django.conf import settings
from dulwich.repo import NotGitRepository, Repo from dulwich.repo import NotGitRepository, Repo
from .api_version import INVENTREE_API_VERSION from .api_version import INVENTREE_API_VERSION
# InvenTree software version # InvenTree software version
INVENTREE_SW_VERSION = "0.12.0 dev" INVENTREE_SW_VERSION = "0.13.0 dev"
# Discover git # Discover git
try: try:
main_repo = Repo(pathlib.Path(__file__).parent.parent.parent) main_repo = Repo(pathlib.Path(__file__).parent.parent.parent)
main_commit = main_repo[main_repo.head()] main_commit = main_repo[main_repo.head()]
except NotGitRepository: except (NotGitRepository, FileNotFoundError):
main_commit = None main_commit = None
@@ -130,3 +132,51 @@ def inventreeCommitDate():
commit_dt = dt.fromtimestamp(main_commit.commit_time) + td(seconds=main_commit.commit_timezone) commit_dt = dt.fromtimestamp(main_commit.commit_time) + td(seconds=main_commit.commit_timezone)
return str(commit_dt.date()) 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)

@@ -27,12 +27,11 @@ from allauth.account.views import (EmailView, LoginView,
PasswordResetFromKeyView) PasswordResetFromKeyView)
from allauth.socialaccount.forms import DisconnectForm from allauth.socialaccount.forms import DisconnectForm
from allauth.socialaccount.views import ConnectionsView from allauth.socialaccount.views import ConnectionsView
from allauth_2fa.views import TwoFactorRemove
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
from common.models import ColorTheme, InvenTreeSetting import common.models as common_models
from common.settings import currency_code_default, currency_codes import common.settings as common_settings
from part.models import PartCategory from part.models import PartCategory
from users.models import RuleSet, check_user_role from users.models import RuleSet, check_user_role
@@ -514,10 +513,10 @@ class SettingsView(TemplateView):
"""Add data for template.""" """Add data for template."""
ctx = super().get_context_data(**kwargs).copy() ctx = super().get_context_data(**kwargs).copy()
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key') ctx['settings'] = common_models.InvenTreeSetting.objects.all().order_by('key')
ctx["base_currency"] = currency_code_default() ctx["base_currency"] = common_settings.currency_code_default()
ctx["currencies"] = currency_codes ctx["currencies"] = common_settings.currency_codes
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange") ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
@@ -525,8 +524,10 @@ class SettingsView(TemplateView):
# When were the rates last updated? # When were the rates last updated?
try: try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange') backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
ctx["rates_updated"] = backend.last_update if backend.exists():
backend = backend.first()
ctx["rates_updated"] = backend.last_update
except Exception: except Exception:
ctx["rates_updated"] = None ctx["rates_updated"] = None
@@ -620,8 +621,8 @@ class AppearanceSelectView(RedirectView):
def get_user_theme(self): def get_user_theme(self):
"""Get current user color theme.""" """Get current user color theme."""
try: try:
user_theme = ColorTheme.objects.filter(user=self.request.user).get() user_theme = common_models.ColorTheme.objects.filter(user=self.request.user).get()
except ColorTheme.DoesNotExist: except common_models.ColorTheme.DoesNotExist:
user_theme = None user_theme = None
return user_theme return user_theme
@@ -635,7 +636,7 @@ class AppearanceSelectView(RedirectView):
# Create theme entry if user did not select one yet # Create theme entry if user did not select one yet
if not user_theme: if not user_theme:
user_theme = ColorTheme() user_theme = common_models.ColorTheme()
user_theme.user = request.user user_theme.user = request.user
user_theme.name = theme user_theme.name = theme
@@ -662,9 +663,3 @@ class NotificationsView(TemplateView):
"""View for showing notifications.""" """View for showing notifications."""
template_name = "InvenTree/notifications/notifications.html" template_name = "InvenTree/notifications/notifications.html"
# Custom 2FA removal form to allow custom redirect URL
class CustomTwoFactorRemove(TwoFactorRemove):
"""Specify custom URL redirect."""
success_url = reverse_lazy("settings")

@@ -6,7 +6,7 @@ from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field from import_export.fields import Field
from import_export import widgets from import_export import widgets
from build.models import Build, BuildItem from build.models import Build, BuildLine, BuildItem
from InvenTree.admin import InvenTreeResource from InvenTree.admin import InvenTreeResource
import part.models import part.models
@@ -87,18 +87,33 @@ class BuildItemAdmin(admin.ModelAdmin):
"""Class for managing the BuildItem model via the admin interface""" """Class for managing the BuildItem model via the admin interface"""
list_display = ( list_display = (
'build',
'stock_item', 'stock_item',
'quantity' 'quantity'
) )
autocomplete_fields = [ autocomplete_fields = [
'build', 'build_line',
'bom_item',
'stock_item', 'stock_item',
'install_into', '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(Build, BuildAdmin)
admin.site.register(BuildItem, BuildItemAdmin) admin.site.register(BuildItem, BuildItemAdmin)
admin.site.register(BuildLine, BuildLineAdmin)

@@ -1,5 +1,6 @@
"""JSON API for the Build app.""" """JSON API for the Build app."""
from django.db.models import F, Q
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -9,14 +10,16 @@ from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView, StatusView from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
from generic.states.api import StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, BuildStatusGroups
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import common.models
import build.admin import build.admin
import build.serializers import build.serializers
from build.models import Build, BuildItem, BuildOrderAttachment from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
import part.models import part.models
from users.models import Owner from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
@@ -41,9 +44,9 @@ class BuildFilter(rest_filters.FilterSet):
def filter_active(self, queryset, name, value): def filter_active(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are active.""" """Filter the queryset to either include or exclude orders which are active."""
if str2bool(value): if str2bool(value):
return queryset.filter(status__in=BuildStatus.ACTIVE_CODES) return queryset.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
else: else:
return queryset.exclude(status__in=BuildStatus.ACTIVE_CODES) return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES)
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue') overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
@@ -87,6 +90,21 @@ class BuildFilter(rest_filters.FilterSet):
lookup_expr="iexact" 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): class BuildList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of Build objects. """API endpoint for accessing a list of Build objects.
@@ -112,11 +130,13 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
'completed', 'completed',
'issued_by', 'issued_by',
'responsible', 'responsible',
'project_code',
'priority', 'priority',
] ]
ordering_field_aliases = { ordering_field_aliases = {
'reference': ['reference_int', 'reference'], 'reference': ['reference_int', 'reference'],
'project_code': ['project_code__code'],
} }
ordering = '-reference' ordering = '-reference'
@@ -127,6 +147,7 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
'part__name', 'part__name',
'part__IPN', 'part__IPN',
'part__description', 'part__description',
'project_code__code',
'priority', 'priority',
] ]
@@ -250,6 +271,103 @@ class BuildUnallocate(CreateAPI):
return ctx 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'))
available = rest_filters.BooleanFilter(label=_('Available'), method='filter_available')
def filter_available(self, queryset, name, value):
"""Filter by whether there is sufficient stock available for each BuildLine:
To determine this, we need to know:
- The quantity required for each BuildLine
- The quantity available for each BuildLine
- The quantity allocated for each BuildLine
"""
flt = Q(quantity__lte=F('total_available_stock') + F('allocated'))
if str2bool(value):
return queryset.filter(flt)
else:
return queryset.exclude(flt)
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 = 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: class BuildOrderContextMixin:
"""Mixin class which adds build order as serializer context variable.""" """Mixin class which adds build order as serializer context variable."""
@@ -372,9 +490,8 @@ class BuildItemFilter(rest_filters.FilterSet):
"""Metaclass option""" """Metaclass option"""
model = BuildItem model = BuildItem
fields = [ fields = [
'build', 'build_line',
'stock_item', 'stock_item',
'bom_item',
'install_into', 'install_into',
] ]
@@ -383,6 +500,11 @@ class BuildItemFilter(rest_filters.FilterSet):
field_name='stock_item__part', 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') tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
def filter_tracked(self, queryset, name, value): def filter_tracked(self, queryset, name, value):
@@ -408,10 +530,9 @@ class BuildItemList(ListCreateAPI):
try: try:
params = self.request.query_params params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', False)) for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']:
kwargs['build_detail'] = str2bool(params.get('build_detail', False)) if key in params:
kwargs['location_detail'] = str2bool(params.get('location_detail', False)) kwargs[key] = str2bool(params.get(key, False))
kwargs['stock_detail'] = str2bool(params.get('stock_detail', True))
except AttributeError: except AttributeError:
pass pass
@@ -422,9 +543,8 @@ class BuildItemList(ListCreateAPI):
queryset = BuildItem.objects.all() queryset = BuildItem.objects.all()
queryset = queryset.select_related( queryset = queryset.select_related(
'bom_item', 'build_line',
'bom_item__sub_part', 'build_line__build',
'build',
'install_into', 'install_into',
'stock_item', 'stock_item',
'stock_item__location', 'stock_item__location',
@@ -434,7 +554,7 @@ class BuildItemList(ListCreateAPI):
return queryset return queryset
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""Customm query filtering for the BuildItem list.""" """Custom query filtering for the BuildItem list."""
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
params = self.request.query_params params = self.request.query_params
@@ -486,6 +606,12 @@ build_api_urls = [
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), 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 # Build Items
re_path(r'^item/', include([ re_path(r'^item/', include([
path(r'<int:pk>/', include([ path(r'<int:pk>/', include([

@@ -0,0 +1,28 @@
# 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')},
},
),
]

@@ -0,0 +1,97 @@
# 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,
),
]

@@ -0,0 +1,19 @@
# 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'),
),
]

@@ -0,0 +1,95 @@
# 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,
)
]

@@ -0,0 +1,26 @@
# 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',
),
]

@@ -0,0 +1,20 @@
# 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'),
),
]

@@ -1,7 +1,7 @@
"""Build database model definitions.""" """Build database model definitions."""
import decimal import decimal
import logging
import os import os
from datetime import datetime from datetime import datetime
@@ -21,7 +21,7 @@ from mptt.exceptions import InvalidMove
from rest_framework import serializers from rest_framework import serializers
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode, BuildStatusGroups
from build.validators import generate_next_build_reference, validate_build_order_reference from build.validators import generate_next_build_reference, validate_build_order_reference
@@ -32,14 +32,18 @@ import InvenTree.models
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import common.models
from common.notifications import trigger_notification
from plugin.events import trigger_event from plugin.events import trigger_event
import common.notifications
import part.models import part.models
import stock.models import stock.models
import users.models import users.models
logger = logging.getLogger('inventree')
class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin): class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects. """A Build object organises the creation of new StockItem objects from other existing StockItem objects.
@@ -69,7 +73,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
verbose_name = _("Build Order") verbose_name = _("Build Order")
verbose_name_plural = _("Build Orders") verbose_name_plural = _("Build Orders")
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) OVERDUE_FILTER = Q(status__in=BuildStatusGroups.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
# Global setting for specifying reference pattern # Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN' REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
@@ -129,10 +133,10 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
return queryset return queryset
# Order was completed within the specified range # Order was completed within the specified range
completed = Q(status=BuildStatus.COMPLETE) & Q(completion_date__gte=min_date) & Q(completion_date__lte=max_date) completed = Q(status=BuildStatus.COMPLETE.value) & Q(completion_date__gte=min_date) & Q(completion_date__lte=max_date)
# Order target date falls within specified range # Order target date falls within specified range
pending = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date) pending = Q(status__in=BuildStatusGroups.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date)
# TODO - Construct a queryset for "overdue" orders # TODO - Construct a queryset for "overdue" orders
@@ -231,7 +235,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
status = models.PositiveIntegerField( status = models.PositiveIntegerField(
verbose_name=_('Build Status'), verbose_name=_('Build Status'),
default=BuildStatus.PENDING, default=BuildStatus.PENDING.value,
choices=BuildStatus.items(), choices=BuildStatus.items(),
validators=[MinValueValidator(0)], validators=[MinValueValidator(0)],
help_text=_('Build status code') help_text=_('Build status code')
@@ -298,6 +302,14 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
help_text=_('Priority of this build order') help_text=_('Priority of this build order')
) )
project_code = models.ForeignKey(
common.models.ProjectCode,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Project Code'),
help_text=_('Project code for this build order'),
)
def sub_builds(self, cascade=True): def sub_builds(self, cascade=True):
"""Return all Build Order objects under this one.""" """Return all Build Order objects under this one."""
if cascade: if cascade:
@@ -331,36 +343,32 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@property @property
def active(self): def active(self):
"""Return True if this build is active.""" """Return True if this build is active."""
return self.status in BuildStatus.ACTIVE_CODES return self.status in BuildStatusGroups.ACTIVE_CODES
@property @property
def bom_items(self): def tracked_line_items(self):
"""Returns the BOM items for the part referenced by this BuildOrder.""" """Returns the "trackable" BOM lines for this BuildOrder."""
return self.part.get_bom_items()
@property return self.build_lines.filter(bom_item__sub_part__trackable=True)
def tracked_bom_items(self):
"""Returns the "trackable" BOM items for this BuildOrder."""
items = self.bom_items
items = items.filter(sub_part__trackable=True)
return items def has_tracked_line_items(self):
def has_tracked_bom_items(self):
"""Returns True if this BuildOrder has trackable BomItems.""" """Returns True if this BuildOrder has trackable BomItems."""
return self.tracked_bom_items.count() > 0 return self.tracked_line_items.count() > 0
@property @property
def untracked_bom_items(self): def untracked_line_items(self):
"""Returns the "non trackable" BOM items for this BuildOrder.""" """Returns the "non trackable" BOM items for this BuildOrder."""
items = self.bom_items
items = items.filter(sub_part__trackable=False)
return items return self.build_lines.filter(bom_item__sub_part__trackable=False)
def has_untracked_bom_items(self): @property
def are_untracked_parts_allocated(self):
"""Returns True if all untracked parts are allocated for this BuildOrder."""
return self.is_fully_allocated(tracked=False)
def has_untracked_line_items(self):
"""Returns True if this BuildOrder has non trackable BomItems.""" """Returns True if this BuildOrder has non trackable BomItems."""
return self.untracked_bom_items.count() > 0 return self.has_untracked_line_items.count() > 0
@property @property
def remaining(self): def remaining(self):
@@ -422,6 +430,11 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
return quantity return quantity
def is_partially_allocated(self):
"""Test is this build order has any stock allocated against it"""
return self.allocated_stock.count() > 0
@property @property
def incomplete_outputs(self): def incomplete_outputs(self):
"""Return all the "incomplete" build outputs.""" """Return all the "incomplete" build outputs."""
@@ -478,21 +491,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@property @property
def can_complete(self): def can_complete(self):
"""Returns True if this build can be "completed". """Returns True if this BuildOrder is ready to be completed
- Must not have any outstanding build outputs - Must not have any outstanding build outputs
- 'completed' value must meet (or exceed) the 'quantity' value - Completed count must meet the required quantity
- Untracked parts must be allocated
""" """
if self.incomplete_count > 0: if self.incomplete_count > 0:
return False return False
if self.remaining > 0: if self.remaining > 0:
return False return False
if not self.are_untracked_parts_allocated(): if not self.is_fully_allocated(tracked=False):
return False return False
# No issues!
return True return True
@transaction.atomic @transaction.atomic
@@ -503,7 +517,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
self.completion_date = datetime.now().date() self.completion_date = datetime.now().date()
self.completed_by = user self.completed_by = user
self.status = BuildStatus.COMPLETE self.status = BuildStatus.COMPLETE.value
self.save() self.save()
# Remove untracked allocated stock # Remove untracked allocated stock
@@ -511,7 +525,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# Ensure that there are no longer any BuildItem objects # Ensure that there are no longer any BuildItem objects
# which point to this Build Order # which point to this Build Order
self.allocated_stock.all().delete() self.allocated_stock.delete()
# Register an event # Register an event
trigger_event('build.completed', id=self.pk) trigger_event('build.completed', id=self.pk)
@@ -547,7 +561,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
} }
} }
common.notifications.trigger_notification( trigger_notification(
build, build,
'build.completed', 'build.completed',
targets=targets, targets=targets,
@@ -566,13 +580,14 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
remove_allocated_stock = kwargs.get('remove_allocated_stock', False) remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False) remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
# Handle stock allocations # Find all BuildItem objects associated with this Build
for build_item in self.allocated_stock.all(): items = self.allocated_stock
if remove_allocated_stock: if remove_allocated_stock:
build_item.complete_allocation(user) for item in items:
item.complete_allocation(user)
build_item.delete() items.delete()
# Remove incomplete outputs (if required) # Remove incomplete outputs (if required)
if remove_incomplete_outputs: if remove_incomplete_outputs:
@@ -585,26 +600,25 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
self.completion_date = datetime.now().date() self.completion_date = datetime.now().date()
self.completed_by = user self.completed_by = user
self.status = BuildStatus.CANCELLED self.status = BuildStatus.CANCELLED.value
self.save() self.save()
trigger_event('build.cancelled', id=self.pk) trigger_event('build.cancelled', id=self.pk)
@transaction.atomic @transaction.atomic
def unallocateStock(self, bom_item=None, output=None): def deallocate_stock(self, build_line=None, output=None):
"""Unallocate stock from this Build. """Deallocate stock from this Build.
Args: Args:
bom_item: Specify a particular BomItem to unallocate stock against build_line: Specify a particular BuildLine instance to un-allocate stock against
output: Specify a particular StockItem (output) to unallocate stock against output: Specify a particular StockItem (output) to un-allocate stock against
""" """
allocations = BuildItem.objects.filter( allocations = self.allocated_stock.filter(
build=self,
install_into=output install_into=output
) )
if bom_item: if build_line:
allocations = allocations.filter(bom_item=bom_item) allocations = allocations.filter(build_line=build_line)
allocations.delete() allocations.delete()
@@ -729,7 +743,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
_add_tracking_entry(output, user) _add_tracking_entry(output, user)
if self.status == BuildStatus.PENDING: if self.status == BuildStatus.PENDING:
self.status = BuildStatus.PRODUCTION self.status = BuildStatus.PRODUCTION.value
self.save() self.save()
@transaction.atomic @transaction.atomic
@@ -737,7 +751,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
"""Remove a build output from the database. """Remove a build output from the database.
Executes: Executes:
- Unallocate any build items against the output - Deallocate any build items against the output
- Delete the output StockItem - Delete the output StockItem
""" """
if not output: if not output:
@@ -749,8 +763,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
if output.build != self: if output.build != self:
raise ValidationError(_("Build output does not match Build Order")) raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output # Deallocate all build items against the output
self.unallocateStock(output=output) self.deallocate_stock(output=output)
# Remove the build output from the database # Remove the build output from the database
output.delete() output.delete()
@@ -758,36 +772,47 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@transaction.atomic @transaction.atomic
def trim_allocated_stock(self): def trim_allocated_stock(self):
"""Called after save to reduce allocated stock if the build order is now overallocated.""" """Called after save to reduce allocated stock if the build order is now overallocated."""
allocations = BuildItem.objects.filter(build=self)
# Only need to worry about untracked stock here # Only need to worry about untracked stock here
for bom_item in self.untracked_bom_items: for build_line in self.untracked_line_items:
reduce_by = self.allocated_quantity(bom_item) - self.required_quantity(bom_item)
if reduce_by <= 0: reduce_by = build_line.allocated_quantity() - build_line.quantity
continue # all OK
if reduce_by <= 0:
continue
# Find BuildItem objects to trim
for item in BuildItem.objects.filter(build_line=build_line):
# find builditem(s) to trim
for a in allocations.filter(bom_item=bom_item):
# Previous item completed the job # Previous item completed the job
if reduce_by == 0: if reduce_by <= 0:
break break
# Easy case - this item can just be reduced. # Easy case - this item can just be reduced.
if a.quantity > reduce_by: if item.quantity > reduce_by:
a.quantity -= reduce_by item.quantity -= reduce_by
a.save() item.save()
break break
# Harder case, this item needs to be deleted, and any remainder # Harder case, this item needs to be deleted, and any remainder
# taken from the next items in the list. # taken from the next items in the list.
reduce_by -= a.quantity reduce_by -= item.quantity
a.delete() item.delete()
@property
def allocated_stock(self):
"""Returns a QuerySet object of all BuildItem objects which point back to this Build"""
return BuildItem.objects.filter(
build_line__build=self
)
@transaction.atomic @transaction.atomic
def subtract_allocated_stock(self, user): def subtract_allocated_stock(self, user):
"""Called when the Build is marked as "complete", this function removes the allocated untracked items from stock.""" """Called when the Build is marked as "complete", this function removes the allocated untracked items from stock."""
# Find all BuildItem objects which point to this build
items = self.allocated_stock.filter( items = self.allocated_stock.filter(
stock_item__part__trackable=False build_line__bom_item__sub_part__trackable=False
) )
# Remove stock # Remove stock
@@ -831,7 +856,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# Update build output item # Update build output item
output.is_building = False output.is_building = False
output.status = StockStatus.REJECTED output.status = StockStatus.REJECTED.value
output.location = location output.location = location
output.save(add_note=False) output.save(add_note=False)
@@ -851,7 +876,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
notes=notes, notes=notes,
deltas={ deltas={
'location': location.pk, 'location': location.pk,
'status': StockStatus.REJECTED, 'status': StockStatus.REJECTED.value,
'buildorder': self.pk, 'buildorder': self.pk,
} }
) )
@@ -865,7 +890,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
""" """
# Select the location for the build output # Select the location for the build output
location = kwargs.get('location', self.destination) location = kwargs.get('location', self.destination)
status = kwargs.get('status', StockStatus.OK) status = kwargs.get('status', StockStatus.OK.value)
notes = kwargs.get('notes', '') notes = kwargs.get('notes', '')
# List the allocated BuildItem objects for the given output # List the allocated BuildItem objects for the given output
@@ -934,8 +959,13 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
else: else:
return 3 return 3
# Get a list of all 'untracked' BOM items new_items = []
for bom_item in self.untracked_bom_items:
# Auto-allocation is only possible for "untracked" line items
for line_item in self.untracked_line_items.all():
# Find the referenced BomItem
bom_item = line_item.bom_item
if bom_item.consumable: if bom_item.consumable:
# Do not auto-allocate stock to consumable BOM items # Do not auto-allocate stock to consumable BOM items
@@ -947,7 +977,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
variant_parts = bom_item.sub_part.get_descendants(include_self=False) variant_parts = bom_item.sub_part.get_descendants(include_self=False)
unallocated_quantity = self.unallocated_quantity(bom_item) unallocated_quantity = line_item.unallocated_quantity()
if unallocated_quantity <= 0: if unallocated_quantity <= 0:
# This BomItem is fully allocated, we can continue # This BomItem is fully allocated, we can continue
@@ -998,18 +1028,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# or all items are "interchangeable" and we don't care where we take stock from # or all items are "interchangeable" and we don't care where we take stock from
for stock_item in available_stock: for stock_item in available_stock:
# Skip inactive parts
if not stock_item.part.active:
continue
# How much of the stock item is "available" for allocation? # How much of the stock item is "available" for allocation?
quantity = min(unallocated_quantity, stock_item.unallocated_quantity()) quantity = min(unallocated_quantity, stock_item.unallocated_quantity())
if quantity > 0: if quantity > 0:
try: try:
BuildItem.objects.create( new_items.append(BuildItem(
build=self, build_line=line_item,
bom_item=bom_item,
stock_item=stock_item, stock_item=stock_item,
quantity=quantity, quantity=quantity,
) ))
# Subtract the required quantity # Subtract the required quantity
unallocated_quantity -= quantity unallocated_quantity -= quantity
@@ -1022,163 +1056,83 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# We have now fully-allocated this BomItem - no need to continue! # We have now fully-allocated this BomItem - no need to continue!
break break
def required_quantity(self, bom_item, output=None): # Bulk-create the new BuildItem objects
"""Get the quantity of a part required to complete the particular build output. BuildItem.objects.bulk_create(new_items)
def unallocated_lines(self, tracked=None):
"""Returns a list of BuildLine objects which have not been fully allocated."""
lines = self.build_lines.all()
if tracked is True:
lines = lines.filter(bom_item__sub_part__trackable=True)
elif tracked is False:
lines = lines.filter(bom_item__sub_part__trackable=False)
unallocated_lines = []
for line in lines:
if not line.is_fully_allocated():
unallocated_lines.append(line)
return unallocated_lines
def is_fully_allocated(self, tracked=None):
"""Test if the BuildOrder has been fully allocated.
This is *true* if *all* associated BuildLine items have sufficient allocation
Arguments:
tracked: If True, only consider tracked BuildLine items. If False, only consider untracked BuildLine items.
Returns:
True if the BuildOrder has been fully allocated, otherwise False
"""
lines = self.unallocated_lines(tracked=tracked)
return len(lines) == 0
def is_output_fully_allocated(self, output):
"""Determine if the specified output (StockItem) has been fully allocated for this build
Args: Args:
bom_item: The Part object output: StockItem object
output: The particular build output (StockItem)
To determine if the output has been fully allocated,
we need to test all "trackable" BuildLine objects
""" """
quantity = bom_item.quantity
if output: for line in self.build_lines.filter(bom_item__sub_part__trackable=True):
quantity *= output.quantity # Grab all BuildItem objects which point to this output
else: allocations = BuildItem.objects.filter(
quantity *= self.quantity build_line=line,
install_into=output,
return quantity
def allocated_bom_items(self, bom_item, output=None):
"""Return all BuildItem objects which allocate stock of <bom_item> to <output>.
Note that the bom_item may allow variants, or direct substitutes,
making things difficult.
Args:
bom_item: The BomItem object
output: Build output (StockItem).
"""
allocations = BuildItem.objects.filter(
build=self,
bom_item=bom_item,
install_into=output,
)
return allocations
def allocated_quantity(self, bom_item, output=None):
"""Return the total quantity of given part allocated to a given build output."""
allocations = self.allocated_bom_items(bom_item, output)
allocated = allocations.aggregate(
q=Coalesce(
Sum('quantity'),
0,
output_field=models.DecimalField(),
) )
)
return allocated['q'] allocated = allocations.aggregate(
q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
)
def unallocated_quantity(self, bom_item, output=None): # The amount allocated against an output must at least equal the BOM quantity
"""Return the total unallocated (remaining) quantity of a part against a particular output.""" if allocated['q'] < line.bom_item.quantity:
required = self.required_quantity(bom_item, output)
allocated = self.allocated_quantity(bom_item, output)
return max(required - allocated, 0)
def is_bom_item_allocated(self, bom_item, output=None):
"""Test if the supplied BomItem has been fully allocated"""
if bom_item.consumable:
# Consumable BOM items do not need to be allocated
return True
return self.unallocated_quantity(bom_item, output) == 0
def is_fully_allocated(self, output):
"""Returns True if the particular build output is fully allocated."""
# If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
if not self.is_bom_item_allocated(bom_item, output):
return False return False
# All parts must be fully allocated! # At this stage, we can assume that the output is fully allocated
return True return True
def is_partially_allocated(self, output): def is_overallocated(self):
"""Returns True if the particular build output is (at least) partially allocated.""" """Test if the BuildOrder has been over-allocated.
# If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items: Returns:
True if any BuildLine has been over-allocated.
"""
if self.allocated_quantity(bom_item, output) > 0: for line in self.build_lines.all():
if line.is_overallocated():
return True return True
return False return False
def are_untracked_parts_allocated(self):
"""Returns True if the un-tracked parts are fully allocated for this BuildOrder."""
return self.is_fully_allocated(None)
def has_overallocated_parts(self, output=None):
"""Check if parts have been 'over-allocated' against the specified output.
Note: If output=None, test un-tracked parts
"""
bom_items = self.tracked_bom_items if output else self.untracked_bom_items
for bom_item in bom_items:
if self.allocated_quantity(bom_item, output) > self.required_quantity(bom_item, output):
return True
return False
def unallocated_bom_items(self, output):
"""Return a list of bom items which have *not* been fully allocated against a particular output."""
unallocated = []
# If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
if not self.is_bom_item_allocated(bom_item, output):
unallocated.append(bom_item)
return unallocated
@property
def required_parts(self):
"""Returns a list of parts required to build this part (BOM)."""
parts = []
for item in self.bom_items:
parts.append(item.sub_part)
return parts
@property
def required_parts_to_complete_build(self):
"""Returns a list of parts required to complete the full build.
TODO: 2022-01-06 : This method needs to be improved, it is very inefficient in terms of DB hits!
"""
parts = []
for bom_item in self.bom_items:
# Get remaining quantity needed
required_quantity_to_complete_build = self.remaining * bom_item.quantity - self.allocated_quantity(bom_item)
# Compare to net stock
if bom_item.sub_part.net_stock < required_quantity_to_complete_build:
parts.append(bom_item.sub_part)
return parts
@property @property
def is_active(self): def is_active(self):
"""Is this build active? """Is this build active?
@@ -1187,13 +1141,59 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
- PENDING - PENDING
- HOLDING - HOLDING
""" """
return self.status in BuildStatus.ACTIVE_CODES return self.status in BuildStatusGroups.ACTIVE_CODES
@property @property
def is_complete(self): def is_complete(self):
"""Returns True if the build status is COMPLETE.""" """Returns True if the build status is COMPLETE."""
return self.status == BuildStatus.COMPLETE return self.status == BuildStatus.COMPLETE
@transaction.atomic
def create_build_line_items(self, prevent_duplicates=True):
"""Create BuildLine objects for each BOM line in this BuildOrder."""
lines = []
bom_items = self.part.get_bom_items()
logger.info(f"Creating BuildLine objects for BuildOrder {self.pk} ({len(bom_items)} items))")
# Iterate through each part required to build the parent part
for bom_item in bom_items:
if prevent_duplicates:
if BuildLine.objects.filter(build=self, bom_item=bom_item).exists():
logger.info(f"BuildLine already exists for BuildOrder {self.pk} and BomItem {bom_item.pk}")
continue
# Calculate required quantity
quantity = bom_item.get_required_quantity(self.quantity)
lines.append(
BuildLine(
build=self,
bom_item=bom_item,
quantity=quantity
)
)
BuildLine.objects.bulk_create(lines)
logger.info(f"Created {len(lines)} BuildLine objects for BuildOrder")
@transaction.atomic
def update_build_line_items(self):
"""Rebuild required quantity field for each BuildLine object"""
lines_to_update = []
for line in self.build_lines.all():
line.quantity = line.bom_item.get_required_quantity(self.quantity)
lines_to_update.append(line)
BuildLine.objects.bulk_update(lines_to_update, ['quantity'])
logger.info(f"Updated {len(lines_to_update)} BuildLine objects for BuildOrder")
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') @receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
def after_save_build(sender, instance: Build, created: bool, **kwargs): def after_save_build(sender, instance: Build, created: bool, **kwargs):
@@ -1204,14 +1204,23 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
from . import tasks as build_tasks from . import tasks as build_tasks
if created: if instance:
# A new Build has just been created
# Run checks on required parts if created:
InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance) # A new Build has just been created
# Notify the responsible users that the build order has been created # Generate initial BuildLine objects for the Build
InvenTree.helpers_model.notify_responsible(instance, sender, exclude=instance.issued_by) instance.create_build_line_items()
# Run checks on required parts
InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance)
# Notify the responsible users that the build order has been created
InvenTree.helpers_model.notify_responsible(instance, sender, exclude=instance.issued_by)
else:
# Update BuildLine objects if the Build quantity has changed
instance.update_build_line_items()
class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment): class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
@@ -1224,6 +1233,87 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments') build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(models.Model):
"""A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically.
- A BuildLine entry is created for each BOM item associated with the part
- The quantity is set to the quantity required to build the part (including overage)
- BuildItem objects are associated with a particular BuildLine
Once a build has been created, BuildLines can (optionally) be removed from the Build
Attributes:
build: Link to a Build object
bom_item: Link to a BomItem object
quantity: Number of units required for the Build
"""
class Meta:
"""Model meta options"""
unique_together = [
('build', 'bom_item'),
]
@staticmethod
def get_api_url():
"""Return the API URL used to access this model"""
return reverse('api-build-line-list')
build = models.ForeignKey(
Build, on_delete=models.CASCADE,
related_name='build_lines', help_text=_('Build object')
)
bom_item = models.ForeignKey(
part.models.BomItem,
on_delete=models.CASCADE,
related_name='build_lines',
)
quantity = models.DecimalField(
decimal_places=5,
max_digits=15,
default=1,
validators=[MinValueValidator(0)],
verbose_name=_('Quantity'),
help_text=_('Required quantity for build order'),
)
@property
def part(self):
"""Return the sub_part reference from the link bom_item"""
return self.bom_item.sub_part
def allocated_quantity(self):
"""Calculate the total allocated quantity for this BuildLine"""
# Queryset containing all BuildItem objects allocated against this BuildLine
allocations = self.allocations.all()
allocated = allocations.aggregate(
q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
)
return allocated['q']
def unallocated_quantity(self):
"""Return the unallocated quantity for this BuildLine"""
return max(self.quantity - self.allocated_quantity(), 0)
def is_fully_allocated(self):
"""Return True if this BuildLine is fully allocated"""
if self.bom_item.consumable:
return True
return self.allocated_quantity() >= self.quantity
def is_overallocated(self):
"""Return True if this BuildLine is over-allocated"""
return self.allocated_quantity() > self.quantity
class BuildItem(InvenTree.models.MetadataMixin, models.Model): class BuildItem(InvenTree.models.MetadataMixin, models.Model):
"""A BuildItem links multiple StockItem objects to a Build. """A BuildItem links multiple StockItem objects to a Build.
@@ -1231,16 +1321,16 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
Attributes: Attributes:
build: Link to a Build object build: Link to a Build object
bom_item: Link to a BomItem object (may or may not point to the same part as the build) build_line: Link to a BuildLine object (this is a "line item" within a build)
stock_item: Link to a StockItem object stock_item: Link to a StockItem object
quantity: Number of units allocated quantity: Number of units allocated
install_into: Destination stock item (or None) install_into: Destination stock item (or None)
""" """
class Meta: class Meta:
"""Serializer metaclass""" """Model meta options"""
unique_together = [ unique_together = [
('build', 'stock_item', 'install_into'), ('build_line', 'stock_item', 'install_into'),
] ]
@staticmethod @staticmethod
@@ -1303,8 +1393,10 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
'quantity': _('Quantity must be 1 for serialized stock') 'quantity': _('Quantity must be 1 for serialized stock')
}) })
except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist): except stock.models.StockItem.DoesNotExist:
pass raise ValidationError("Stock item must be specified")
except part.models.Part.DoesNotExist:
raise ValidationError("Part must be specified")
""" """
Attempt to find the "BomItem" which links this BuildItem to the build. Attempt to find the "BomItem" which links this BuildItem to the build.
@@ -1312,7 +1404,7 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
- If a BomItem is already set, and it is valid, then we are ok! - If a BomItem is already set, and it is valid, then we are ok!
""" """
bom_item_valid = False valid = False
if self.bom_item and self.build: if self.bom_item and self.build:
""" """
@@ -1327,39 +1419,51 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
""" """
if self.build.part == self.bom_item.part: if self.build.part == self.bom_item.part:
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item) valid = self.bom_item.is_stock_item_valid(self.stock_item)
elif self.bom_item.inherited: elif self.bom_item.inherited:
if self.build.part in self.bom_item.part.get_descendants(include_self=False): if self.build.part in self.bom_item.part.get_descendants(include_self=False):
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item) valid = self.bom_item.is_stock_item_valid(self.stock_item)
# If the existing BomItem is *not* valid, try to find a match # If the existing BomItem is *not* valid, try to find a match
if not bom_item_valid: if not valid:
if self.build and self.stock_item: if self.build and self.stock_item:
ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True) ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True)
for idx, ancestor in enumerate(ancestors): for idx, ancestor in enumerate(ancestors):
try: build_line = BuildLine.objects.filter(
bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor) build=self.build,
except part.models.BomItem.DoesNotExist: bom_item__part=ancestor,
continue )
# A matching BOM item has been found! if build_line.exists():
if idx == 0 or bom_item.allow_variants: line = build_line.first()
bom_item_valid = True
self.bom_item = bom_item if idx == 0 or line.bom_item.allow_variants:
break valid = True
self.build_line = line
break
# BomItem did not exist or could not be validated. # BomItem did not exist or could not be validated.
# Search for a new one # Search for a new one
if not bom_item_valid: if not valid:
raise ValidationError({ raise ValidationError({
'stock_item': _("Selected stock item not found in BOM") 'stock_item': _("Selected stock item does not match BOM line")
}) })
@property
def build(self):
"""Return the BuildOrder associated with this BuildItem"""
return self.build_line.build if self.build_line else None
@property
def bom_item(self):
"""Return the BomItem associated with this BuildItem"""
return self.build_line.bom_item if self.build_line else None
@transaction.atomic @transaction.atomic
def complete_allocation(self, user, notes=''): def complete_allocation(self, user, notes=''):
"""Complete the allocation of this BuildItem into the output stock item. """Complete the allocation of this BuildItem into the output stock item.
@@ -1409,43 +1513,10 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
} }
) )
def getStockItemThumbnail(self): build_line = models.ForeignKey(
"""Return qualified URL for part thumbnail image.""" BuildLine,
thumb_url = None on_delete=models.SET_NULL, null=True,
related_name='allocations',
if self.stock_item and self.stock_item.part:
try:
# Try to extract the thumbnail
thumb_url = self.stock_item.part.image.thumbnail.url
except Exception:
pass
if thumb_url is None and self.bom_item and self.bom_item.sub_part:
try:
thumb_url = self.bom_item.sub_part.image.thumbnail.url
except Exception:
pass
if thumb_url is not None:
return InvenTree.helpers.getMediaUrl(thumb_url)
else:
return InvenTree.helpers.getBlankThumbnail()
build = models.ForeignKey(
Build,
on_delete=models.CASCADE,
related_name='allocated_stock',
verbose_name=_('Build'),
help_text=_('Build to allocate parts')
)
# Internal model which links part <-> sub_part
# We need to track this separately, to allow for "variant' stock
bom_item = models.ForeignKey(
part.models.BomItem,
on_delete=models.CASCADE,
related_name='allocate_build_items',
blank=True, null=True,
) )
stock_item = models.ForeignKey( stock_item = models.ForeignKey(

@@ -4,8 +4,11 @@ from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.db.models import Case, When, Value 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 BooleanField from django.db.models import BooleanField
from django.db.models.functions import Coalesce
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@@ -20,11 +23,12 @@ from InvenTree.status_codes import StockStatus
from stock.models import generate_batch_code, StockItem, StockLocation from stock.models import generate_batch_code, StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer from stock.serializers import StockItemSerializerBrief, LocationSerializer
from part.models import BomItem from common.serializers import ProjectCodeSerializer
from part.serializers import PartSerializer, PartBriefSerializer import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer
from .models import Build, BuildItem, BuildOrderAttachment from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
class BuildSerializer(InvenTreeModelSerializer): class BuildSerializer(InvenTreeModelSerializer):
@@ -46,6 +50,8 @@ class BuildSerializer(InvenTreeModelSerializer):
'parent', 'parent',
'part', 'part',
'part_detail', 'part_detail',
'project_code',
'project_code_detail',
'overdue', 'overdue',
'reference', 'reference',
'sales_order', 'sales_order',
@@ -87,6 +93,8 @@ class BuildSerializer(InvenTreeModelSerializer):
barcode_hash = serializers.CharField(read_only=True) barcode_hash = serializers.CharField(read_only=True)
project_code_detail = ProjectCodeSerializer(source='project_code', many=False, read_only=True)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible. """Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
@@ -170,7 +178,7 @@ class BuildOutputSerializer(serializers.Serializer):
if to_complete: if to_complete:
# The build output must have all tracked parts allocated # The build output must have all tracked parts allocated
if not build.is_fully_allocated(output): if not build.is_output_fully_allocated(output):
# Check if the user has specified that incomplete allocations are ok # 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)) accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False))
@@ -490,8 +498,8 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
) )
status = serializers.ChoiceField( status = serializers.ChoiceField(
choices=list(StockStatus.items()), choices=StockStatus.items(),
default=StockStatus.OK, default=StockStatus.OK.value,
label=_("Status"), label=_("Status"),
) )
@@ -562,7 +570,7 @@ class BuildCancelSerializer(serializers.Serializer):
build = self.context['build'] build = self.context['build']
return { return {
'has_allocated_stock': build.is_partially_allocated(None), 'has_allocated_stock': build.is_partially_allocated(),
'incomplete_outputs': build.incomplete_count, 'incomplete_outputs': build.incomplete_count,
'completed_outputs': build.complete_count, 'completed_outputs': build.complete_count,
} }
@@ -621,8 +629,8 @@ class BuildCompleteSerializer(serializers.Serializer):
build = self.context['build'] build = self.context['build']
return { return {
'overallocated': build.has_overallocated_parts(), 'overallocated': build.is_overallocated(),
'allocated': build.are_untracked_parts_allocated(), 'allocated': build.are_untracked_parts_allocated,
'remaining': build.remaining, 'remaining': build.remaining,
'incomplete': build.incomplete_count, 'incomplete': build.incomplete_count,
} }
@@ -639,7 +647,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_overallocated' field is required""" """Check if the 'accept_overallocated' field is required"""
build = self.context['build'] build = self.context['build']
if build.has_overallocated_parts(output=None) and value == OverallocationChoice.REJECT: if build.is_overallocated() and value == OverallocationChoice.REJECT:
raise ValidationError(_('Some stock items have been overallocated')) raise ValidationError(_('Some stock items have been overallocated'))
return value return value
@@ -655,7 +663,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_unallocated' field is required""" """Check if the 'accept_unallocated' field is required"""
build = self.context['build'] 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')) raise ValidationError(_('Required stock has not been fully allocated'))
return value return value
@@ -706,12 +714,12 @@ class BuildUnallocationSerializer(serializers.Serializer):
- bom_item: Filter against a particular BOM line item - bom_item: Filter against a particular BOM line item
""" """
bom_item = serializers.PrimaryKeyRelatedField( build_line = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(), queryset=BuildLine.objects.all(),
many=False, many=False,
allow_null=True, allow_null=True,
required=False, required=False,
label=_('BOM Item'), label=_('Build Line'),
) )
output = serializers.PrimaryKeyRelatedField( output = serializers.PrimaryKeyRelatedField(
@@ -742,8 +750,8 @@ class BuildUnallocationSerializer(serializers.Serializer):
data = self.validated_data data = self.validated_data
build.unallocateStock( build.deallocate_stock(
bom_item=data['bom_item'], build_line=data['build_line'],
output=data['output'] output=data['output']
) )
@@ -754,34 +762,34 @@ class BuildAllocationItemSerializer(serializers.Serializer):
class Meta: class Meta:
"""Serializer metaclass""" """Serializer metaclass"""
fields = [ fields = [
'bom_item', 'build_item',
'stock_item', 'stock_item',
'quantity', 'quantity',
'output', 'output',
] ]
bom_item = serializers.PrimaryKeyRelatedField( build_line = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(), queryset=BuildLine.objects.all(),
many=False, many=False,
allow_null=False, allow_null=False,
required=True, required=True,
label=_('BOM Item'), label=_('Build Line Item'),
) )
def validate_bom_item(self, bom_item): def validate_build_line(self, build_line):
"""Check if the parts match""" """Check if the parts match"""
build = self.context['build'] build = self.context['build']
# BomItem should point to the same 'part' as the parent build # BomItem should point to the same 'part' as the parent build
if build.part != bom_item.part: if build.part != build_line.bom_item.part:
# If not, it may be marked as "inherited" from a parent part # If not, it may be marked as "inherited" from a parent part
if bom_item.inherited and build.part in bom_item.part.get_descendants(include_self=False): if build_line.bom_item.inherited and build.part in build_line.bom_item.part.get_descendants(include_self=False):
pass pass
else: else:
raise ValidationError(_("bom_item.part must point to the same part as the build order")) raise ValidationError(_("bom_item.part must point to the same part as the build order"))
return bom_item return build_line
stock_item = serializers.PrimaryKeyRelatedField( stock_item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(), queryset=StockItem.objects.all(),
@@ -824,8 +832,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
"""Perform data validation for this item""" """Perform data validation for this item"""
super().validate(data) super().validate(data)
build = self.context['build'] build_line = data['build_line']
bom_item = data['bom_item']
stock_item = data['stock_item'] stock_item = data['stock_item']
quantity = data['quantity'] quantity = data['quantity']
output = data.get('output', None) output = data.get('output', None)
@@ -847,20 +854,20 @@ class BuildAllocationItemSerializer(serializers.Serializer):
}) })
# Output *must* be set for trackable parts # Output *must* be set for trackable parts
if output is None and bom_item.sub_part.trackable: if output is None and build_line.bom_item.sub_part.trackable:
raise ValidationError({ raise ValidationError({
'output': _('Build output must be specified for allocation of tracked parts'), 'output': _('Build output must be specified for allocation of tracked parts'),
}) })
# Output *cannot* be set for un-tracked parts # Output *cannot* be set for un-tracked parts
if output is not None and not bom_item.sub_part.trackable: if output is not None and not build_line.bom_item.sub_part.trackable:
raise ValidationError({ raise ValidationError({
'output': _('Build output cannot be specified for allocation of untracked parts'), 'output': _('Build output cannot be specified for allocation of untracked parts'),
}) })
# Check if this allocation would be unique # Check if this allocation would be unique
if BuildItem.objects.filter(build=build, stock_item=stock_item, install_into=output).exists(): if BuildItem.objects.filter(build_line=build_line, stock_item=stock_item, install_into=output).exists():
raise ValidationError(_('This stock item has already been allocated to this build output')) raise ValidationError(_('This stock item has already been allocated to this build output'))
return data return data
@@ -894,24 +901,21 @@ class BuildAllocationSerializer(serializers.Serializer):
items = data.get('items', []) items = data.get('items', [])
build = self.context['build']
with transaction.atomic(): with transaction.atomic():
for item in items: for item in items:
bom_item = item['bom_item'] build_line = item['build_line']
stock_item = item['stock_item'] stock_item = item['stock_item']
quantity = item['quantity'] quantity = item['quantity']
output = item.get('output', None) output = item.get('output', None)
# Ignore allocation for consumable BOM items # Ignore allocation for consumable BOM items
if bom_item.consumable: if build_line.bom_item.consumable:
continue continue
try: try:
# Create a new BuildItem to allocate stock # Create a new BuildItem to allocate stock
BuildItem.objects.create( BuildItem.objects.create(
build=build, build_line=build_line,
bom_item=bom_item,
stock_item=stock_item, stock_item=stock_item,
quantity=quantity, quantity=quantity,
install_into=output install_into=output
@@ -993,43 +997,37 @@ class BuildItemSerializer(InvenTreeModelSerializer):
model = BuildItem model = BuildItem
fields = [ fields = [
'pk', 'pk',
'bom_part',
'build', 'build',
'build_detail', 'build_line',
'install_into', 'install_into',
'location',
'location_detail',
'part',
'part_detail',
'stock_item', 'stock_item',
'quantity',
'location_detail',
'part_detail',
'stock_item_detail', 'stock_item_detail',
'quantity' 'build_detail',
] ]
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True) # Annotated fields
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True) build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
# Extra (optional) detail fields # Extra (optional) detail fields
part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True) part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
build_detail = BuildSerializer(source='build', many=False, read_only=True)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
location_detail = LocationSerializer(source='stock_item.location', 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() quantity = InvenTreeDecimalField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Determine which extra details fields should be included""" """Determine which extra details fields should be included"""
build_detail = kwargs.pop('build_detail', False) part_detail = kwargs.pop('part_detail', True)
part_detail = kwargs.pop('part_detail', False) location_detail = kwargs.pop('location_detail', True)
location_detail = kwargs.pop('location_detail', False)
stock_detail = kwargs.pop('stock_detail', False) stock_detail = kwargs.pop('stock_detail', False)
build_detail = kwargs.pop('build_detail', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not build_detail:
self.fields.pop('build_detail')
if not part_detail: if not part_detail:
self.fields.pop('part_detail') self.fields.pop('part_detail')
@@ -1039,6 +1037,166 @@ class BuildItemSerializer(InvenTreeModelSerializer):
if not stock_detail: if not stock_detail:
self.fields.pop('stock_item_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',
'total_available_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, pricing=False)
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
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)
total_available_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
"""
queryset = queryset.select_related(
'build', 'bom_item',
)
# Pre-fetch related fields
queryset = queryset.prefetch_related(
'bom_item__sub_part',
'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations',
'bom_item__sub_part__tags',
'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',
'allocations',
'allocations__stock_item',
'allocations__stock_item__part',
'allocations__stock_item__location',
'allocations__stock_item__location__tags',
)
# 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(),
)
)
# Annotate with the 'total available stock'
queryset = queryset.annotate(
total_available_stock=ExpressionWrapper(
F('available_stock') + F('available_substitute_stock') + F('available_variant_stock'),
output_field=FloatField(),
)
)
return queryset
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer): class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for a BuildAttachment.""" """Serializer for a BuildAttachment."""

@@ -15,7 +15,7 @@ import build.models
import InvenTree.email import InvenTree.email
import InvenTree.helpers_model import InvenTree.helpers_model
import InvenTree.tasks import InvenTree.tasks
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatusGroups
from InvenTree.ready import isImportingData from InvenTree.ready import isImportingData
import part.models as part_models import part.models as part_models
@@ -158,7 +158,7 @@ def check_overdue_build_orders():
overdue_orders = build.models.Build.objects.filter( overdue_orders = build.models.Build.objects.filter(
target_date=yesterday, target_date=yesterday,
status__in=BuildStatus.ACTIVE_CODES status__in=BuildStatusGroups.ACTIVE_CODES
) )
for bo in overdue_orders: for bo in overdue_orders:

@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load status_codes %} {% load generic %}
{% load inventree_extras %} {% load inventree_extras %}
{% block page_title %} {% block page_title %}
@@ -108,6 +108,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{% trans "Build Description" %}</td> <td>{% trans "Build Description" %}</td>
<td>{{ build.title }}</td> <td>{{ build.title }}</td>
</tr> </tr>
{% include "project_code_data.html" with instance=build %}
{% include "barcode_data.html" with instance=build %} {% include "barcode_data.html" with instance=build %}
</table> </table>
@@ -150,7 +151,7 @@ src="{% static 'img/blank_image.png' %}"
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td> <td>{% trans "Status" %}</td>
<td> <td>
{% build_status_label build.status %} {% status_label 'build' build.status %}
</td> </td>
</tr> </tr>
{% if build.target_date %} {% if build.target_date %}
@@ -174,7 +175,7 @@ src="{% static 'img/blank_image.png' %}"
{% else %} {% else %}
<span class='fa fa-times-circle icon-red'></span> <span class='fa fa-times-circle icon-red'></span>
{% endif %} {% endif %}
<td>{% trans "Completed" %}</td> <td>{% trans "Completed Outputs" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td> <td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td>
</tr> </tr>
{% if build.parent %} {% if build.parent %}
@@ -217,7 +218,7 @@ src="{% static 'img/blank_image.png' %}"
{% block page_data %} {% block page_data %}
<h3> <h3>
{% build_status_label build.status large=True %} {% status_label 'build' build.status large=True %}
{% if build.is_overdue %} {% if build.is_overdue %}
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span> <span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
{% endif %} {% endif %}

@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %} {% load inventree_extras %}
{% load status_codes %} {% load generic %}
{% block sidebar %} {% block sidebar %}
{% include "build/sidebar.html" %} {% include "build/sidebar.html" %}
@@ -60,14 +60,14 @@
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td> <td>{% trans "Status" %}</td>
<td>{% build_status_label build.status %}</td> <td>{% status_label 'build' build.status %}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-check-circle'></span></td> <td><span class='fas fa-check-circle'></span></td>
<td>{% trans "Completed" %}</td> <td>{% trans "Completed Outputs" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td> <td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td>
</tr> </tr>
{% if build.active and has_untracked_bom_items %} {% if build.active %}
<tr> <tr>
<td><span class='fas fa-list'></span></td> <td><span class='fas fa-list'></span></td>
<td>{% trans "Allocated Parts" %}</td> <td>{% trans "Allocated Parts" %}</td>
@@ -165,9 +165,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='child-button-toolbar'> <div id='child-button-toolbar'>
<div class='button-toolbar container-fluid float-right'> {% include "filter_list.html" with id='sub-build' %}
{% include "filter_list.html" with id='sub-build' %}
</div>
</div> </div>
<table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#child-button-toolbar'></table> <table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#child-button-toolbar'></table>
</div> </div>
@@ -179,9 +177,9 @@
<h4>{% trans "Allocate Stock to Build" %}</h4> <h4>{% trans "Allocate Stock to Build" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.build.add and build.active and has_untracked_bom_items %} {% if roles.build.add and build.active %}
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'> <button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Deallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %} <span class='fas fa-minus-circle'></span> {% trans "Deallocate Stock" %}
</button> </button>
<button class='btn btn-primary' type='button' id='btn-auto-allocate' title='{% trans "Automatically allocate stock to build" %}'> <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" %} <span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
@@ -199,34 +197,10 @@
</div> </div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% if has_untracked_bom_items %} <div id='build-lines-toolbar'>
{% if build.active %} {% include "filter_list.html" with id='buildlines' %}
{% 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> </div>
{% else %} <table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
<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>
</div> </div>
@@ -246,37 +220,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='build-output-toolbar'> <div id='build-output-toolbar'>
<div class='button-toolbar container-fluid'> {% include "filter_list.html" with id='incompletebuilditems' %}
{% 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.change %}
<li><a class='dropdown-item' href='#' id='multi-output-scrap' title='{% trans "Scrap selected build outputs" %}'>
<span class='fas fa-times-circle icon-red'></span> {% trans "Scrap 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> </div>
<table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table> <table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table>
</div> </div>
@@ -362,9 +306,6 @@ onPanelLoad('completed', function() {
build: {{ build.id }}, build: {{ build.id }},
is_building: false, is_building: false,
}, },
buttons: [
'#stock-options',
],
}); });
}); });
@@ -427,38 +368,15 @@ onPanelLoad('outputs', function() {
{% endif %} {% endif %}
}); });
{% 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() { onPanelLoad('allocate', function() {
loadUntrackedStockTable(); // Load the table of line items for this build order
loadBuildLineTable(
"#build-lines-table",
{{ build.pk }},
{}
);
}); });
{% endif %}
$('#btn-create-output').click(function() { $('#btn-create-output').click(function() {
createBuildOutput( createBuildOutput(
@@ -480,66 +398,58 @@ $("#btn-auto-allocate").on('click', function() {
{% if build.take_from %} {% if build.take_from %}
location: {{ build.take_from.pk }}, location: {{ build.take_from.pk }},
{% endif %} {% endif %}
onSuccess: loadUntrackedStockTable, onSuccess: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
} }
); );
}); });
$("#btn-allocate").on('click', function() { function allocateSelectedLines() {
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData"); let data = getTableData('#build-lines-table');
var incomplete_bom_items = []; let unallocated_lines = [];
bom_items.forEach(function(bom_item) { data.forEach(function(line) {
if (bom_item.required > bom_item.allocated) { if (line.allocated < line.quantity) {
incomplete_bom_items.push(bom_item); unallocated_lines.push(line);
} }
}); });
if (incomplete_bom_items.length == 0) { if (unallocated_lines.length == 0) {
showAlertDialog( showAlertDialog(
'{% trans "Allocation Complete" %}', '{% trans "Allocation Complete" %}',
'{% trans "All untracked stock items have been allocated" %}', '{% trans "All lines have been fully allocated" %}',
); );
} else { } else {
allocateStockToBuild( allocateStockToBuild(
{{ build.pk }}, {{ build.pk }},
{{ build.part.pk }}, unallocated_lines,
incomplete_bom_items,
{ {
{% if build.take_from %} {% if build.take_from %}
source_location: {{ build.take_from.pk }}, source_location: {{ build.take_from.pk }},
{% endif %} {% endif %}
success: loadUntrackedStockTable, success: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
} }
); );
} }
}); }
$('#btn-unallocate').on('click', function() { $('#btn-unallocate').on('click', function() {
unallocateStock({{ build.id }}, { deallocateStock({{ build.id }}, {
table: '#allocation-table-untracked', table: '#allocation-table-untracked',
onSuccess: loadUntrackedStockTable, onSuccess: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
}); });
}); });
$('#allocate-selected-items').click(function() { $("#btn-allocate").on('click', function() {
allocateSelectedLines();
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 %} {% endif %}

@@ -24,11 +24,7 @@
<div class='panel-content'> <div class='panel-content'>
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> {% include "filter_list.html" with id="build" %}
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="build" %}
</div>
</div>
</div> </div>
<table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'> <table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>

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

@@ -298,7 +298,7 @@ class BuildTest(BuildAPITest):
expected_code=400, expected_code=400,
) )
bo.status = BuildStatus.CANCELLED bo.status = BuildStatus.CANCELLED.value
bo.save() bo.save()
# Now, we should be able to delete # Now, we should be able to delete
@@ -582,6 +582,9 @@ class BuildAllocationTest(BuildAPITest):
self.build = Build.objects.get(pk=1) 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 # Record number of build items which exist at the start of each test
self.n = BuildItem.objects.count() self.n = BuildItem.objects.count()
@@ -593,7 +596,7 @@ class BuildAllocationTest(BuildAPITest):
self.assertEqual(self.build.part.bom_items.count(), 4) self.assertEqual(self.build.part.bom_items.count(), 4)
# No items yet allocated to this build # No items yet allocated to this build
self.assertEqual(self.build.allocated_stock.count(), 0) self.assertEqual(BuildItem.objects.filter(build_line__build=self.build).count(), 0)
def test_get(self): def test_get(self):
"""A GET request to the endpoint should return an error.""" """A GET request to the endpoint should return an error."""
@@ -634,7 +637,7 @@ class BuildAllocationTest(BuildAPITest):
{ {
"items": [ "items": [
{ {
"bom_item": 1, # M2x4 LPHS "build_line": 1, # M2x4 LPHS
"stock_item": 2, # 5,000 screws available "stock_item": 2, # 5,000 screws available
} }
] ]
@@ -658,7 +661,7 @@ class BuildAllocationTest(BuildAPITest):
expected_code=400 expected_code=400
).data ).data
self.assertIn("This field is required", str(data["items"][0]["bom_item"])) self.assertIn("This field is required", str(data["items"][0]["build_line"]))
# Missing stock_item # Missing stock_item
data = self.post( data = self.post(
@@ -666,7 +669,7 @@ class BuildAllocationTest(BuildAPITest):
{ {
"items": [ "items": [
{ {
"bom_item": 1, "build_line": 1,
"quantity": 5000, "quantity": 5000,
} }
] ]
@@ -681,12 +684,25 @@ class BuildAllocationTest(BuildAPITest):
def test_invalid_bom_item(self): def test_invalid_bom_item(self):
"""Test by passing an invalid BOM item.""" """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( data = self.post(
self.url, self.url,
{ {
"items": [ "items": [
{ {
"bom_item": 5, "build_line": wrong_line.pk,
"stock_item": 11, "stock_item": 11,
"quantity": 500, "quantity": 500,
} }
@@ -695,19 +711,31 @@ class BuildAllocationTest(BuildAPITest):
expected_code=400 expected_code=400
).data ).data
self.assertIn('must point to the same part', str(data)) self.assertIn('Selected stock item does not match BOM line', str(data))
def test_valid_data(self): def test_valid_data(self):
"""Test with valid data. """Test with valid data.
This should result in creation of a new BuildItem object 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.post(
self.url, self.url,
{ {
"items": [ "items": [
{ {
"bom_item": 1, "build_line": right_line.pk,
"stock_item": 2, "stock_item": 2,
"quantity": 5000, "quantity": 5000,
} }
@@ -749,16 +777,22 @@ class BuildOverallocationTest(BuildAPITest):
cls.state = {} cls.state = {}
cls.allocation = {} cls.allocation = {}
for i, bi in enumerate(cls.build.part.bom_items.all()): items_to_create = []
rq = cls.build.required_quantity(bi, None) + i + 1
si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first()
cls.state[bi.sub_part] = (si, si.quantity, rq) for idx, build_line in enumerate(cls.build.build_lines.all()):
BuildItem.objects.create( required = build_line.quantity + idx + 1
build=cls.build, 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,
stock_item=si, stock_item=si,
quantity=rq, quantity=required,
) ))
BuildItem.objects.bulk_create(items_to_create)
# create and complete outputs # create and complete outputs
cls.build.create_build_output(cls.build.quantity) cls.build.create_build_output(cls.build.quantity)
@@ -822,9 +856,10 @@ class BuildOverallocationTest(BuildAPITest):
self.assertTrue(self.build.is_complete) self.assertTrue(self.build.is_complete)
# Check stock items have reduced only by bom requirement (overallocation trimmed) # Check stock items have reduced only by bom requirement (overallocation trimmed)
for bi in self.build.part.bom_items.all(): for line in self.build.build_lines.all():
si, oq, _ = self.state[bi.sub_part]
rq = self.build.required_quantity(bi, None) si, oq, _ = self.state[line.bom_item.sub_part]
rq = line.quantity
si.refresh_from_db() si.refresh_from_db()
self.assertEqual(si.quantity, oq - rq) self.assertEqual(si.quantity, oq - rq)
@@ -843,7 +878,7 @@ class BuildListTest(BuildAPITest):
builds = self.get(self.url, data={'active': True}) builds = self.get(self.url, data={'active': True})
self.assertEqual(len(builds.data), 1) self.assertEqual(len(builds.data), 1)
builds = self.get(self.url, data={'status': BuildStatus.COMPLETE}) builds = self.get(self.url, data={'status': BuildStatus.COMPLETE.value})
self.assertEqual(len(builds.data), 4) self.assertEqual(len(builds.data), 4)
builds = self.get(self.url, data={'overdue': False}) builds = self.get(self.url, data={'overdue': False})
@@ -863,7 +898,7 @@ class BuildListTest(BuildAPITest):
reference="BO-0006", reference="BO-0006",
quantity=10, quantity=10,
title='Just some thing', title='Just some thing',
status=BuildStatus.PRODUCTION, status=BuildStatus.PRODUCTION.value,
target_date=in_the_past target_date=in_the_past
) )

@@ -13,7 +13,7 @@ from InvenTree import status_codes as status
import common.models import common.models
import build.tasks import build.tasks
from build.models import Build, BuildItem, generate_next_build_reference from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from part.models import Part, BomItem, BomItemSubstitute from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem from stock.models import StockItem
from users.models import Owner from users.models import Owner
@@ -107,6 +107,11 @@ class BuildTestBase(TestCase):
issued_by=get_user_model().objects.get(pk=1), 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 # Create some build output (StockItem) objects
cls.output_1 = StockItem.objects.create( cls.output_1 = StockItem.objects.create(
part=cls.assembly, part=cls.assembly,
@@ -248,13 +253,10 @@ class BuildTest(BuildTestBase):
for output in self.build.get_build_outputs().all(): for output in self.build.get_build_outputs().all():
self.assertFalse(self.build.is_fully_allocated(output)) self.assertFalse(self.build.is_fully_allocated(output))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1, self.output_1)) self.assertFalse(self.line_1.is_fully_allocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2, self.output_2)) self.assertFalse(self.line_2.is_overallocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15) self.assertEqual(self.line_1.allocated_quantity(), 0)
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) self.assertFalse(self.build.is_complete)
@@ -264,25 +266,25 @@ class BuildTest(BuildTestBase):
stock = StockItem.objects.create(part=self.assembly, quantity=99) stock = StockItem.objects.create(part=self.assembly, quantity=99)
# Create a BuiltItem which points to an invalid StockItem # Create a BuiltItem which points to an invalid StockItem
b = BuildItem(stock_item=stock, build=self.build, quantity=10) b = BuildItem(stock_item=stock, build_line=self.line_2, quantity=10)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
b.save() b.save()
# Create a BuildItem which has too much stock assigned # Create a BuildItem which has too much stock assigned
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999) b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
b.clean() b.clean()
# Negative stock? Not on my watch! # Negative stock? Not on my watch!
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=-99) b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=-99)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
b.clean() b.clean()
# Ok, what about we make one that does *not* fail? # Ok, what about we make one that does *not* fail?
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10) b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b.save() b.save()
def test_duplicate_bom_line(self): def test_duplicate_bom_line(self):
@@ -302,13 +304,24 @@ class BuildTest(BuildTestBase):
allocations: Map of {StockItem: quantity} allocations: Map of {StockItem: quantity}
""" """
items_to_create = []
for item, quantity in allocations.items(): for item, quantity in allocations.items():
BuildItem.objects.create(
# Find an appropriate BuildLine to allocate against
line = BuildLine.objects.filter(
build=self.build, build=self.build,
bom_item__sub_part=item.part
).first()
items_to_create.append(BuildItem(
build_line=line,
stock_item=item, stock_item=item,
quantity=quantity, quantity=quantity,
install_into=output install_into=output
) ))
BuildItem.objects.bulk_create(items_to_create)
def test_partial_allocation(self): def test_partial_allocation(self):
"""Test partial allocation of stock""" """Test partial allocation of stock"""
@@ -321,7 +334,7 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertTrue(self.build.is_fully_allocated(self.output_1)) self.assertTrue(self.build.is_output_fully_allocated(self.output_1))
# Partially allocate tracked stock against build output 2 # Partially allocate tracked stock against build output 2
self.allocate_stock( self.allocate_stock(
@@ -331,7 +344,7 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertFalse(self.build.is_fully_allocated(self.output_2)) self.assertFalse(self.build.is_output_fully_allocated(self.output_2))
# Partially allocate untracked stock against build # Partially allocate untracked stock against build
self.allocate_stock( self.allocate_stock(
@@ -342,11 +355,12 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertFalse(self.build.is_fully_allocated(None)) self.assertFalse(self.build.is_output_fully_allocated(None))
unallocated = self.build.unallocated_bom_items(None) # Find lines which are *not* fully allocated
unallocated = self.build.unallocated_lines()
self.assertEqual(len(unallocated), 2) self.assertEqual(len(unallocated), 3)
self.allocate_stock( self.allocate_stock(
None, None,
@@ -357,17 +371,17 @@ class BuildTest(BuildTestBase):
self.assertFalse(self.build.is_fully_allocated(None)) self.assertFalse(self.build.is_fully_allocated(None))
unallocated = self.build.unallocated_bom_items(None) unallocated = self.build.unallocated_lines()
self.assertEqual(len(unallocated), 1)
self.build.unallocateStock()
unallocated = self.build.unallocated_bom_items(None)
self.assertEqual(len(unallocated), 2) self.assertEqual(len(unallocated), 2)
self.assertFalse(self.build.are_untracked_parts_allocated()) 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.stock_2_1.quantity = 500 self.stock_2_1.quantity = 500
self.stock_2_1.save() self.stock_2_1.save()
@@ -381,7 +395,7 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertTrue(self.build.are_untracked_parts_allocated()) self.assertTrue(self.build.is_fully_allocated(tracked=False))
def test_overallocation_and_trim(self): def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function""" """Test overallocation of stock and trim function"""
@@ -425,10 +439,10 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertTrue(self.build.has_overallocated_parts(None)) self.assertTrue(self.build.is_overallocated())
self.build.trim_allocated_stock() self.build.trim_allocated_stock()
self.assertFalse(self.build.has_overallocated_parts(None)) self.assertFalse(self.build.is_overallocated())
self.build.complete_build_output(self.output_1, None) self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None) self.build.complete_build_output(self.output_2, None)
@@ -587,7 +601,7 @@ class BuildTest(BuildTestBase):
"""Unit tests for the metadata field.""" """Unit tests for the metadata field."""
# Make sure a BuildItem exists before trying to run this test # Make sure a BuildItem exists before trying to run this test
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10) b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b.save() b.save()
for model in [Build, BuildItem]: for model in [Build, BuildItem]:
@@ -644,7 +658,7 @@ class AutoAllocationTests(BuildTestBase):
# No build item allocations have been made against the build # No build item allocations have been made against the build
self.assertEqual(self.build.allocated_stock.count(), 0) self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.are_untracked_parts_allocated()) self.assertFalse(self.build.is_fully_allocated(tracked=False))
# Stock is not interchangeable, nothing will happen # Stock is not interchangeable, nothing will happen
self.build.auto_allocate_stock( self.build.auto_allocate_stock(
@@ -652,15 +666,15 @@ class AutoAllocationTests(BuildTestBase):
substitutes=False, substitutes=False,
) )
self.assertFalse(self.build.are_untracked_parts_allocated()) self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertEqual(self.build.allocated_stock.count(), 0) self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1)) self.assertFalse(self.line_1.is_fully_allocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) self.assertFalse(self.line_2.is_fully_allocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50) self.assertEqual(self.line_1.unallocated_quantity(), 50)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30) self.assertEqual(self.line_2.unallocated_quantity(), 30)
# This time we expect stock to be allocated! # This time we expect stock to be allocated!
self.build.auto_allocate_stock( self.build.auto_allocate_stock(
@@ -669,15 +683,15 @@ class AutoAllocationTests(BuildTestBase):
optional_items=True, optional_items=True,
) )
self.assertFalse(self.build.are_untracked_parts_allocated()) self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertEqual(self.build.allocated_stock.count(), 7) self.assertEqual(self.build.allocated_stock.count(), 7)
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1)) self.assertTrue(self.line_1.is_fully_allocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) self.assertFalse(self.line_2.is_fully_allocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5) self.assertEqual(self.line_2.unallocated_quantity(), 5)
# This time, allow substitute parts to be used! # This time, allow substitute parts to be used!
self.build.auto_allocate_stock( self.build.auto_allocate_stock(
@@ -685,12 +699,11 @@ class AutoAllocationTests(BuildTestBase):
substitutes=True, substitutes=True,
) )
# self.assertEqual(self.build.allocated_stock.count(), 8) self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.line_2.unallocated_quantity(), 5)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5.0)
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1)) self.assertTrue(self.line_1.is_fully_allocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) self.assertFalse(self.line_2.is_fully_allocated())
def test_fully_auto(self): def test_fully_auto(self):
"""We should be able to auto-allocate against a build in a single go""" """We should be able to auto-allocate against a build in a single go"""
@@ -701,7 +714,7 @@ class AutoAllocationTests(BuildTestBase):
optional_items=True, optional_items=True,
) )
self.assertTrue(self.build.are_untracked_parts_allocated()) self.assertTrue(self.build.is_fully_allocated(tracked=False))
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0) self.assertEqual(self.line_2.unallocated_quantity(), 0)

@@ -19,22 +19,15 @@ class TestForwardMigrations(MigratorTestCase):
name='Widget', name='Widget',
description='Buildable Part', description='Buildable Part',
active=True, 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 = self.old_state.apps.get_model('build', 'build')
Build.objects.create( Build.objects.create(
part=buildable_part, part=buildable_part,
title='A build of some stuff', title='A build of some stuff',
quantity=50 quantity=50,
) )
def test_items_exist(self): def test_items_exist(self):
@@ -67,7 +60,8 @@ class TestReferenceMigration(MigratorTestCase):
part = Part.objects.create( part = Part.objects.create(
name='Part', name='Part',
description='A test part' description='A test part',
level=0, lft=0, rght=0, tree_id=0,
) )
Build = self.old_state.apps.get_model('build', 'build') Build = self.old_state.apps.get_model('build', 'build')
@@ -158,3 +152,139 @@ class TestReferencePatternMigration(MigratorTestCase):
pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN') pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN')
self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}') 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,
)

@@ -39,7 +39,5 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
part = build.part part = build.part
ctx['part'] = 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 return ctx

@@ -2,6 +2,7 @@
import json import json
from django.conf import settings
from django.http.response import HttpResponse from django.http.response import HttpResponse
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@@ -121,8 +122,13 @@ class CurrencyExchangeView(APIView):
# Information on last update # Information on last update
try: try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange') backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
updated = backend.last_update
if backend.exists():
backend = backend.first()
updated = backend.last_update
else:
updated = None
except Exception: except Exception:
updated = None updated = None
@@ -480,6 +486,29 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] 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 = [ settings_api_urls = [
# User settings # User settings
re_path(r'^user/', include([ re_path(r'^user/', include([
@@ -496,7 +525,7 @@ settings_api_urls = [
path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'), path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
# Notification Settings List # Notification Settings List
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'), re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notification-setting-list'),
])), ])),
# Global settings # Global settings
@@ -552,6 +581,11 @@ common_api_urls = [
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'), 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 = [ admin_api_urls = [

@@ -4,15 +4,40 @@ import django.core.validators
from django.db import migrations, models 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): class Migration(migrations.Migration):
initial = True initial = True
atomic = False
dependencies = [ dependencies = [
] ]
operations = [ operations = [
migrations.CreateModel( CreateModelOrSkip(
name='Currency', name='Currency',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),

@@ -1,4 +1,4 @@
# Generated by Django 3.2.18 on 2023-04-17 05:54 # Generated by Django 3.2.18 on 2023-04-17 05:55
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models

@@ -127,6 +127,7 @@ class SettingsKeyType(TypedDict, total=False):
before_save: Function that gets called after save with *args, **kwargs (optional) before_save: Function that gets called after save with *args, **kwargs (optional)
after_save: Function that gets called after save with *args, **kwargs (optional) after_save: Function that gets called after save with *args, **kwargs (optional)
protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False) protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False)
required: Is this setting required to work, can be used in combination with .check_all_settings(...) (optional, default: False)
model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional) model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional)
""" """
@@ -140,6 +141,7 @@ class SettingsKeyType(TypedDict, total=False):
before_save: Callable[..., None] before_save: Callable[..., None]
after_save: Callable[..., None] after_save: Callable[..., None]
protected: bool protected: bool
required: bool
model: str model: str
@@ -250,13 +252,15 @@ class BaseInvenTreeSetting(models.Model):
return {key: getattr(self, key, None) for key in self.extra_unique_fields if hasattr(self, key)} return {key: getattr(self, key, None) for key in self.extra_unique_fields if hasattr(self, key)}
@classmethod @classmethod
def allValues(cls, exclude_hidden=False, **kwargs): def all_settings(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
"""Return a dict of "all" defined global settings. """Return a list of "all" defined settings.
This performs a single database lookup, This performs a single database lookup,
and then any settings which are not *in* the database and then any settings which are not *in* the database
are assigned their default values are assigned their default values
""" """
filters = cls.get_filters(**kwargs)
results = cls.objects.all() results = cls.objects.all()
if exclude_hidden: if exclude_hidden:
@@ -264,45 +268,83 @@ class BaseInvenTreeSetting(models.Model):
results = results.exclude(key__startswith='_') results = results.exclude(key__startswith='_')
# Optionally filter by other keys # Optionally filter by other keys
results = results.filter(**cls.get_filters(**kwargs)) results = results.filter(**filters)
settings: Dict[str, BaseInvenTreeSetting] = {}
# Query the database # Query the database
settings = {}
for setting in results: for setting in results:
if setting.key: if setting.key:
settings[setting.key.upper()] = setting.value settings[setting.key.upper()] = setting
# Specify any "default" values which are not in the database # Specify any "default" values which are not in the database
for key in cls.SETTINGS.keys(): settings_definition = settings_definition or cls.SETTINGS
for key, setting in settings_definition.items():
if key.upper() not in settings: if key.upper() not in settings:
settings[key.upper()] = cls.get_setting_default(key) settings[key.upper()] = cls(
key=key.upper(),
value=cls.get_setting_default(key, **filters),
**filters
)
if exclude_hidden: # remove any hidden settings
hidden = cls.SETTINGS[key].get('hidden', False) if exclude_hidden and setting.get("hidden", False):
del settings[key.upper()]
if hidden: # format settings values and remove protected
# Remove hidden items for key, setting in settings.items():
del settings[key.upper()] validator = cls.get_setting_validator(key, **filters)
for key, value in settings.items(): if cls.is_protected(key, **filters) and setting.value != "":
validator = cls.get_setting_validator(key) setting.value = '***'
if cls.is_protected(key):
value = '***'
elif cls.validator_is_bool(validator): elif cls.validator_is_bool(validator):
value = InvenTree.helpers.str2bool(value) setting.value = InvenTree.helpers.str2bool(setting.value)
elif cls.validator_is_int(validator): elif cls.validator_is_int(validator):
try: try:
value = int(value) setting.value = int(setting.value)
except ValueError: except ValueError:
value = cls.get_setting_default(key) setting.value = cls.get_setting_default(key, **filters)
settings[key] = value
return settings return settings
@classmethod
def allValues(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
"""Return a dict of "all" defined global settings.
This performs a single database lookup,
and then any settings which are not *in* the database
are assigned their default values
"""
all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs)
settings: Dict[str, Any] = {}
for key, setting in all_settings.items():
settings[key] = setting.value
return settings
@classmethod
def check_all_settings(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
"""Check if all required settings are set by definition.
Returns:
is_valid: Are all required settings defined
missing_settings: List of all settings that are missing (empty if is_valid is 'True')
"""
all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs)
missing_settings: List[str] = []
for setting in all_settings.values():
if setting.required:
value = setting.value or cls.get_setting_default(setting.key, **kwargs)
if value == "":
missing_settings.append(setting.key.upper())
return len(missing_settings) == 0, missing_settings
@classmethod @classmethod
def get_setting_definition(cls, key, **kwargs): def get_setting_definition(cls, key, **kwargs):
"""Return the 'definition' of a particular settings value, as a dict object. """Return the 'definition' of a particular settings value, as a dict object.
@@ -829,7 +871,7 @@ class BaseInvenTreeSetting(models.Model):
@classmethod @classmethod
def is_protected(cls, key, **kwargs): def is_protected(cls, key, **kwargs):
"""Check if the setting value is protected.""" """Check if the setting value is protected."""
setting = cls.get_setting_definition(key, **kwargs) setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs))
return setting.get('protected', False) return setting.get('protected', False)
@@ -838,6 +880,18 @@ class BaseInvenTreeSetting(models.Model):
"""Returns if setting is protected from rendering.""" """Returns if setting is protected from rendering."""
return self.__class__.is_protected(self.key, **self.get_filters_for_instance()) return self.__class__.is_protected(self.key, **self.get_filters_for_instance())
@classmethod
def is_required(cls, key, **kwargs):
"""Check if this setting value is required."""
setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs))
return setting.get("required", False)
@property
def required(self):
"""Returns if setting is required."""
return self.__class__.is_required(self.key, **self.get_filters_for_instance())
def settings_group_options(): def settings_group_options():
"""Build up group tuple for settings based on your choices.""" """Build up group tuple for settings based on your choices."""
@@ -1249,6 +1303,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': '', 'default': '',
}, },
'PART_PARAMETER_ENFORCE_UNITS': {
'name': _('Enforce Parameter Units'),
'description': _('If units are provided, parameter values must match the specified units'),
'default': True,
'validator': bool,
},
'PRICING_DECIMAL_PLACES_MIN': { 'PRICING_DECIMAL_PLACES_MIN': {
'name': _('Minimum Pricing Decimal Places'), 'name': _('Minimum Pricing Decimal Places'),
'description': _('Minimum number of decimal places to display when rendering pricing data'), 'description': _('Minimum number of decimal places to display when rendering pricing data'),
@@ -1467,6 +1528,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': '', 'default': '',
}, },
'STOCK_SHOW_INSTALLED_ITEMS': {
'name': _('Show Installed Stock Items'),
'description': _('Display installed stock items in stock tables'),
'default': False,
'validator': bool,
},
'BUILDORDER_REFERENCE_PATTERN': { 'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Pattern'), 'name': _('Build Order Reference Pattern'),
'description': _('Required pattern for generating Build Order reference field'), 'description': _('Required pattern for generating Build Order reference field'),
@@ -1616,13 +1684,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'requires_restart': True, 'requires_restart': True,
}, },
'PLUGIN_CHECK_SIGNATURES': {
'name': _('Check plugin signatures'),
'description': _('Check and show signatures for plugins'),
'default': False,
'validator': bool,
},
# Settings for plugin mixin features # Settings for plugin mixin features
'ENABLE_PLUGINS_URL': { 'ENABLE_PLUGINS_URL': {
'name': _('Enable URL integration'), 'name': _('Enable URL integration'),
@@ -1678,6 +1739,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False, 'default': False,
}, },
'STOCKTAKE_EXCLUDE_EXTERNAL': {
'name': _('Exclude External Locations'),
'description': _('Exclude stock items in external locations from stocktake calculations'),
'validator': bool,
'default': False,
},
'STOCKTAKE_AUTO_DAYS': { 'STOCKTAKE_AUTO_DAYS': {
'name': _('Automatic Stocktake Period'), 'name': _('Automatic Stocktake Period'),
'description': _('Number of days between automatic stocktake recording (set to zero to disable)'), 'description': _('Number of days between automatic stocktake recording (set to zero to disable)'),
@@ -1775,13 +1843,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'PART_RECENT_COUNT': {
'name': _('Recent Part Count'),
'description': _('Number of recent parts to display on index page'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'HOMEPAGE_BOM_REQUIRES_VALIDATION': { 'HOMEPAGE_BOM_REQUIRES_VALIDATION': {
'name': _('Show unvalidated BOMs'), 'name': _('Show unvalidated BOMs'),
'description': _('Show BOMs that await validation on the homepage'), 'description': _('Show BOMs that await validation on the homepage'),
@@ -1796,13 +1857,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'STOCK_RECENT_COUNT': {
'name': _('Recent Stock Count'),
'description': _('Number of recent stock items to display on index page'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'HOMEPAGE_STOCK_LOW': { 'HOMEPAGE_STOCK_LOW': {
'name': _('Show low stock'), 'name': _('Show low stock'),
'description': _('Show low stock items on the homepage'), 'description': _('Show low stock items on the homepage'),

@@ -8,8 +8,8 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import common.models
import InvenTree.helpers import InvenTree.helpers
from common.models import NotificationEntry, NotificationMessage
from InvenTree.ready import isImportingData from InvenTree.ready import isImportingData
from plugin import registry from plugin import registry
from plugin.models import NotificationUserSetting, PluginConfig from plugin.models import NotificationUserSetting, PluginConfig
@@ -247,7 +247,7 @@ class UIMessageNotification(SingleNotificationMethod):
def send(self, target): def send(self, target):
"""Send a UI notification to a user.""" """Send a UI notification to a user."""
NotificationMessage.objects.create( common.models.NotificationMessage.objects.create(
target_object=self.obj, target_object=self.obj,
source_object=target, source_object=target,
user=target, user=target,
@@ -279,7 +279,7 @@ class NotificationBody:
name: str name: str
slug: str slug: str
message: str message: str
template: str template: str = None
class InvenTreeNotificationBodies: class InvenTreeNotificationBodies:
@@ -338,7 +338,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
# Check if we have notified recently... # Check if we have notified recently...
delta = timedelta(days=1) delta = timedelta(days=1)
if NotificationEntry.check_recent(category, obj_ref_value, delta): if common.models.NotificationEntry.check_recent(category, obj_ref_value, delta):
logger.info(f"Notification '{category}' has recently been sent for '{str(obj)}' - SKIPPING") logger.info(f"Notification '{category}' has recently been sent for '{str(obj)}' - SKIPPING")
return return
@@ -398,7 +398,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
logger.error(error) logger.error(error)
# Set delivery flag # Set delivery flag
NotificationEntry.notify(category, obj_ref_value) common.models.NotificationEntry.notify(category, obj_ref_value)
else: else:
logger.info(f"No possible users for notification '{category}'") logger.info(f"No possible users for notification '{category}'")

@@ -1,18 +1,37 @@
"""JSON serializers for common components.""" """JSON serializers for common components."""
from django.urls import reverse from django.urls import reverse
from flags.state import flag_state
from rest_framework import serializers from rest_framework import serializers
from common.models import (InvenTreeSetting, InvenTreeUserSetting, import common.models as common_models
NewsFeedEntry, NotesImage, NotificationMessage,
ProjectCode)
from InvenTree.helpers import get_objectreference from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url from InvenTree.helpers_model import construct_absolute_url
from InvenTree.serializers import (InvenTreeImageSerializerField, from InvenTree.serializers import (InvenTreeImageSerializerField,
InvenTreeModelSerializer) InvenTreeModelSerializer)
class SettingsValueField(serializers.Field):
"""Custom serializer field for a settings value."""
def get_attribute(self, instance):
"""Return the object instance, not the attribute value."""
return instance
def to_representation(self, instance):
"""Return the value of the setting:
- Protected settings are returned as '***'
"""
return '***' if instance.protected else str(instance.value)
def to_internal_value(self, data):
"""Return the internal value of the setting"""
return str(data)
class SettingsSerializer(InvenTreeModelSerializer): class SettingsSerializer(InvenTreeModelSerializer):
"""Base serializer for a settings object.""" """Base serializer for a settings object."""
@@ -30,6 +49,8 @@ class SettingsSerializer(InvenTreeModelSerializer):
api_url = serializers.CharField(read_only=True) api_url = serializers.CharField(read_only=True)
value = SettingsValueField()
def get_choices(self, obj): def get_choices(self, obj):
"""Returns the choices available for a given item.""" """Returns the choices available for a given item."""
results = [] results = []
@@ -45,16 +66,6 @@ class SettingsSerializer(InvenTreeModelSerializer):
return results return results
def get_value(self, obj):
"""Make sure protected values are not returned."""
# never return protected values
if obj.protected:
result = '***'
else:
result = obj.value
return result
class GlobalSettingsSerializer(SettingsSerializer): class GlobalSettingsSerializer(SettingsSerializer):
"""Serializer for the InvenTreeSetting model.""" """Serializer for the InvenTreeSetting model."""
@@ -62,7 +73,7 @@ class GlobalSettingsSerializer(SettingsSerializer):
class Meta: class Meta:
"""Meta options for GlobalSettingsSerializer.""" """Meta options for GlobalSettingsSerializer."""
model = InvenTreeSetting model = common_models.InvenTreeSetting
fields = [ fields = [
'pk', 'pk',
'key', 'key',
@@ -83,7 +94,7 @@ class UserSettingsSerializer(SettingsSerializer):
class Meta: class Meta:
"""Meta options for UserSettingsSerializer.""" """Meta options for UserSettingsSerializer."""
model = InvenTreeUserSetting model = common_models.InvenTreeUserSetting
fields = [ fields = [
'pk', 'pk',
'key', 'key',
@@ -146,7 +157,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
class Meta: class Meta:
"""Meta options for NotificationMessageSerializer.""" """Meta options for NotificationMessageSerializer."""
model = NotificationMessage model = common_models.NotificationMessage
fields = [ fields = [
'pk', 'pk',
'target', 'target',
@@ -207,7 +218,7 @@ class NewsFeedEntrySerializer(InvenTreeModelSerializer):
class Meta: class Meta:
"""Meta options for NewsFeedEntrySerializer.""" """Meta options for NewsFeedEntrySerializer."""
model = NewsFeedEntry model = common_models.NewsFeedEntry
fields = [ fields = [
'pk', 'pk',
'feed_id', 'feed_id',
@@ -241,7 +252,7 @@ class NotesImageSerializer(InvenTreeModelSerializer):
class Meta: class Meta:
"""Meta options for NotesImageSerializer.""" """Meta options for NotesImageSerializer."""
model = NotesImage model = common_models.NotesImage
fields = [ fields = [
'pk', 'pk',
'image', 'image',
@@ -263,9 +274,25 @@ class ProjectCodeSerializer(InvenTreeModelSerializer):
class Meta: class Meta:
"""Meta options for ProjectCodeSerializer.""" """Meta options for ProjectCodeSerializer."""
model = ProjectCode model = common_models.ProjectCode
fields = [ fields = [
'pk', 'pk',
'code', 'code',
'description' 'description'
] ]
class FlagSerializer(serializers.Serializer):
"""Serializer for feature flags."""
def to_representation(self, instance):
"""Return the configuration data as a dictionary."""
request = self.context.get('request')
if not isinstance(instance, str):
instance = list(instance.keys())[0]
data = {'key': instance, 'state': flag_state(instance, request=request)}
if request and request.user.is_superuser:
data['conditions'] = self.instance[instance]
return data

@@ -1,20 +1,23 @@
"""User-configurable settings for the common app.""" """User-configurable settings for the common app."""
import logging
from django.conf import settings from django.conf import settings
from moneyed import CURRENCIES from moneyed import CURRENCIES
logger = logging.getLogger('inventree')
def currency_code_default(): def currency_code_default():
"""Returns the default currency code (or USD if not specified)""" """Returns the default currency code (or USD if not specified)"""
from django.db.utils import ProgrammingError
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
try: try:
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', create=False, cache=False) code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', create=False, cache=False)
except ProgrammingError: # pragma: no cover except Exception: # pragma: no cover
# database is not initialized yet # Database may not yet be ready, no need to throw an error here
code = '' code = ''
if code not in CURRENCIES: if code not in CURRENCIES:
@@ -42,4 +45,4 @@ def stock_expiry_enabled():
"""Returns True if the stock expiry feature is enabled.""" """Returns True if the stock expiry feature is enabled."""
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY') return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY', False, create=False)

@@ -5,6 +5,7 @@ import json
import time import time
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
from unittest import mock
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache from django.core.cache import cache
@@ -105,6 +106,40 @@ class SettingsTest(InvenTreeTestCase):
self.assertIn('PART_COPY_TESTS', result) self.assertIn('PART_COPY_TESTS', result)
self.assertIn('STOCK_OWNERSHIP_CONTROL', result) self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
self.assertIn('SIGNUP_GROUP', result) self.assertIn('SIGNUP_GROUP', result)
self.assertIn('SERVER_RESTART_REQUIRED', result)
result = InvenTreeSetting.allValues(exclude_hidden=True)
self.assertNotIn('SERVER_RESTART_REQUIRED', result)
def test_all_settings(self):
"""Make sure that the all_settings function returns correctly"""
result = InvenTreeSetting.all_settings()
self.assertIn("INVENTREE_INSTANCE", result)
self.assertIsInstance(result['INVENTREE_INSTANCE'], InvenTreeSetting)
@mock.patch("common.models.InvenTreeSetting.get_setting_definition")
def test_check_all_settings(self, get_setting_definition):
"""Make sure that the check_all_settings function returns correctly"""
# define partial schema
settings_definition = {
"AB": { # key that's has not already been accessed
"required": True,
},
"CD": {
"required": True,
"protected": True,
},
"EF": {}
}
def mocked(key, **kwargs):
return settings_definition.get(key, {})
get_setting_definition.side_effect = mocked
self.assertEqual(InvenTreeSetting.check_all_settings(settings_definition=settings_definition), (False, ["AB", "CD"]))
InvenTreeSetting.set_setting('AB', "hello", self.user)
InvenTreeSetting.set_setting('CD', "world", self.user)
self.assertEqual(InvenTreeSetting.check_all_settings(), (True, []))
def run_settings_check(self, key, setting): def run_settings_check(self, key, setting):
"""Test that all settings are valid. """Test that all settings are valid.
@@ -226,7 +261,7 @@ class SettingsTest(InvenTreeTestCase):
cache.clear() cache.clear()
# Generate a number of new usesr # Generate a number of new users
for idx in range(5): for idx in range(5):
get_user_model().objects.create( get_user_model().objects.create(
username=f"User_{idx}", username=f"User_{idx}",
@@ -417,7 +452,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
self.assertTrue(str2bool(response.data['value'])) self.assertTrue(str2bool(response.data['value']))
# Assign some falsey values # Assign some false(ish) values
for v in ['false', False, '0', 'n', 'FalSe']: for v in ['false', False, '0', 'n', 'FalSe']:
self.patch( self.patch(
url, url,
@@ -535,7 +570,7 @@ class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
def test_api_list(self): def test_api_list(self):
"""Test list URL.""" """Test list URL."""
url = reverse('api-notifcation-setting-list') url = reverse('api-notification-setting-list')
self.get(url, expected_code=200) self.get(url, expected_code=200)
@@ -583,7 +618,7 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
# Failure mode tests # Failure mode tests
# Non - exsistant plugin # Non-existent plugin
url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'}) url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'})
response = self.get(url, expected_code=404) response = self.get(url, expected_code=404)
self.assertIn("Plugin 'doesnotexist' not installed", str(response.data)) self.assertIn("Plugin 'doesnotexist' not installed", str(response.data))
@@ -729,7 +764,7 @@ class WebhookMessageTests(TestCase):
class NotificationTest(InvenTreeAPITestCase): class NotificationTest(InvenTreeAPITestCase):
"""Tests for NotificationEntriy.""" """Tests for NotificationEntry."""
fixtures = [ fixtures = [
'users', 'users',
@@ -785,7 +820,7 @@ class NotificationTest(InvenTreeAPITestCase):
messages = NotificationMessage.objects.all() messages = NotificationMessage.objects.all()
# As there are three staff users (including the 'test' user) we expect 30 notifications # As there are three staff users (including the 'test' user) we expect 30 notifications
# However, one user is marked as i nactive # However, one user is marked as inactive
self.assertEqual(messages.count(), 20) self.assertEqual(messages.count(), 20)
# Only 10 messages related to *this* user # Only 10 messages related to *this* user
@@ -875,6 +910,43 @@ class CommonTest(InvenTreeAPITestCase):
self.user.is_superuser = False self.user.is_superuser = False
self.user.save() self.user.save()
def test_flag_api(self):
"""Test flag URLs."""
# Not superuser
response = self.get(reverse('api-flag-list'), expected_code=200)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]['key'], 'EXPERIMENTAL')
# Turn into superuser
self.user.is_superuser = True
self.user.save()
# Successful checks
response = self.get(reverse('api-flag-list'), expected_code=200)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]['key'], 'EXPERIMENTAL')
self.assertTrue(response.data[0]['conditions'])
response = self.get(reverse('api-flag-detail', kwargs={'key': 'EXPERIMENTAL'}), expected_code=200)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data['key'], 'EXPERIMENTAL')
self.assertTrue(response.data['conditions'])
# Try without param -> false
response = self.get(reverse('api-flag-detail', kwargs={'key': 'NEXT_GEN'}), expected_code=200)
self.assertFalse(response.data['state'])
# Try with param -> true
response = self.get(reverse('api-flag-detail', kwargs={'key': 'NEXT_GEN'}), {'ngen': ''}, expected_code=200)
self.assertTrue(response.data['state'])
# Try non existent flag
response = self.get(reverse('api-flag-detail', kwargs={'key': 'NON_EXISTENT'}), expected_code=404)
# Turn into normal user again
self.user.is_superuser = False
self.user.save()
class ColorThemeTest(TestCase): class ColorThemeTest(TestCase):
"""Tests for ColorTheme.""" """Tests for ColorTheme."""

@@ -9,9 +9,9 @@ from import_export.fields import Field
from InvenTree.admin import InvenTreeResource from InvenTree.admin import InvenTreeResource
from part.models import Part from part.models import Part
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment, from .models import (Address, Company, Contact, ManufacturerPart,
ManufacturerPartParameter, SupplierPart, ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPriceBreak) SupplierPart, SupplierPriceBreak)
class CompanyResource(InvenTreeResource): class CompanyResource(InvenTreeResource):
@@ -187,6 +187,60 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part',) autocomplete_fields = ('part',)
class AddressResource(InvenTreeResource):
"""Class for managing Address data import/export"""
class Meta:
"""Metaclass defining extra options"""
model = Address
skip_unchanged = True
report_skipped = False
clean_model_instances = True
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
class AddressAdmin(ImportExportModelAdmin):
"""Admin class for the Address model"""
resource_class = AddressResource
list_display = ('company', 'line1', 'postal_code', 'country')
search_fields = [
'company',
'country',
'postal_code',
]
class ContactResource(InvenTreeResource):
"""Class for managing Contact data import/export"""
class Meta:
"""Metaclass defining extra options"""
model = Contact
skip_unchanged = True
report_skipped = False
clean_model_instances = True
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
class ContactAdmin(ImportExportModelAdmin):
"""Admin class for the Contact model"""
resource_class = ContactResource
list_display = ('company', 'name', 'role', 'email', 'phone')
search_fields = [
'company',
'name',
'email',
]
admin.site.register(Company, CompanyAdmin) admin.site.register(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin) admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin) admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
@@ -194,3 +248,6 @@ admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
admin.site.register(ManufacturerPart, ManufacturerPartAdmin) admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin) admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin) admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
admin.site.register(Address, AddressAdmin)
admin.site.register(Contact, ContactAdmin)

@@ -14,11 +14,12 @@ from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER,
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart, from .models import (Address, Company, CompanyAttachment, Contact,
ManufacturerPartAttachment, ManufacturerPartParameter, ManufacturerPart, ManufacturerPartAttachment,
SupplierPart, SupplierPriceBreak) ManufacturerPartParameter, SupplierPart,
from .serializers import (CompanyAttachmentSerializer, CompanySerializer, SupplierPriceBreak)
ContactSerializer, from .serializers import (AddressSerializer, CompanyAttachmentSerializer,
CompanySerializer, ContactSerializer,
ManufacturerPartAttachmentSerializer, ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer, ManufacturerPartParameterSerializer,
ManufacturerPartSerializer, SupplierPartSerializer, ManufacturerPartSerializer, SupplierPartSerializer,
@@ -135,6 +136,32 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
serializer_class = ContactSerializer serializer_class = ContactSerializer
class AddressList(ListCreateDestroyAPIView):
"""API endpoint for list view of Address model"""
queryset = Address.objects.all()
serializer_class = AddressSerializer
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [
'company',
]
ordering_fields = [
'title',
]
ordering = 'title'
class AddressDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for a single Address object"""
queryset = Address.objects.all()
serializer_class = AddressSerializer
class ManufacturerPartFilter(rest_filters.FilterSet): class ManufacturerPartFilter(rest_filters.FilterSet):
"""Custom API filters for the ManufacturerPart list endpoint.""" """Custom API filters for the ManufacturerPart list endpoint."""
@@ -568,6 +595,11 @@ company_api_urls = [
re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'), re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'),
])), ])),
re_path(r'^address/', include([
path('<int:pk>/', AddressDetail.as_view(), name='api-address-detail'),
re_path(r'^.*$', AddressList.as_view(), name='api-address-list'),
])),
re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'), re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'),
] ]

@@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('common', '0004_inventreesetting'),
('company', '0024_unique_name_email_constraint'), ('company', '0024_unique_name_email_constraint'),
] ]

@@ -8,6 +8,7 @@ import djmoney.models.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('common', '0004_inventreesetting'),
('company', '0038_manufacturerpartparameter'), ('company', '0038_manufacturerpartparameter'),
] ]

@@ -8,6 +8,7 @@ import djmoney.models.validators
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('common', '0004_inventreesetting'),
('company', '0050_alter_company_website'), ('company', '0050_alter_company_website'),
] ]

@@ -8,6 +8,7 @@ import InvenTree.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('stock', '0094_auto_20230220_0025'),
('company', '0058_auto_20230515_0004'), ('company', '0058_auto_20230515_0004'),
] ]

@@ -0,0 +1,33 @@
# Generated by Django 3.2.18 on 2023-05-02 19:56
import InvenTree.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0062_contact_metadata'),
]
operations = [
migrations.CreateModel(
name='Address',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='Title describing the address entry', max_length=100, verbose_name='Address title')),
('primary', models.BooleanField(default=False, help_text='Set as primary address', verbose_name='Primary address')),
('line1', models.CharField(blank=True, help_text='Address line 1', max_length=50, verbose_name='Line 1')),
('line2', models.CharField(blank=True, help_text='Address line 2', max_length=50, verbose_name='Line 2')),
('postal_code', models.CharField(blank=True, help_text='Postal code', max_length=10, verbose_name='Postal code')),
('postal_city', models.CharField(blank=True, help_text='Postal code city', max_length=50, verbose_name='City')),
('province', models.CharField(blank=True, help_text='State or province', max_length=50, verbose_name='State/Province')),
('country', models.CharField(blank=True, help_text='Address country', max_length=50, verbose_name='Country')),
('shipping_notes', models.CharField(blank=True, help_text='Notes for shipping courier', max_length=100, verbose_name='Courier shipping notes')),
('internal_shipping_notes', models.CharField(blank=True, help_text='Shipping notes for internal use', max_length=100, verbose_name='Internal shipping notes')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to address information (external)', verbose_name='Link')),
('company', models.ForeignKey(help_text='Select company', on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='company.company', verbose_name='Company')),
],
),
]

@@ -0,0 +1,37 @@
# Generated by Django 3.2.18 on 2023-05-02 20:41
from django.db import migrations
def move_address_to_new_model(apps, schema_editor):
Company = apps.get_model('company', 'Company')
Address = apps.get_model('company', 'Address')
for company in Company.objects.all():
if company.address != '':
# Address field might exceed length of new model fields
l1 = company.address[:50]
l2 = company.address[50:100]
Address.objects.create(company=company,
title="Primary",
primary=True,
line1=l1,
line2=l2)
company.address = ''
company.save()
def revert_address_move(apps, schema_editor):
Address = apps.get_model('company', 'Address')
for address in Address.objects.all():
address.company.address = f'{address.line1}{address.line2}'
address.company.save()
address.delete()
class Migration(migrations.Migration):
dependencies = [
('company', '0063_auto_20230502_1956'),
]
operations = [
migrations.RunPython(move_address_to_new_model, reverse_code=revert_address_move)
]

@@ -0,0 +1,17 @@
# Generated by Django 3.2.18 on 2023-05-13 14:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0064_move_address_field_to_address_model'),
]
operations = [
migrations.RemoveField(
model_name='company',
name='address',
),
]

@@ -0,0 +1,22 @@
# Generated by Django 3.2.19 on 2023-06-16 20:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0065_remove_company_address'),
]
operations = [
migrations.AlterModelOptions(
name='address',
options={'verbose_name_plural': 'Addresses'},
),
migrations.AlterField(
model_name='address',
name='postal_city',
field=models.CharField(blank=True, help_text='Postal code city/region', max_length=50, verbose_name='City/Region'),
),
]

@@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db.models import Q, Sum, UniqueConstraint from django.db.models import Q, Sum, UniqueConstraint
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -30,7 +30,7 @@ from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin,
InvenTreeNotesMixin, MetadataMixin) InvenTreeNotesMixin, MetadataMixin)
from InvenTree.status_codes import PurchaseOrderStatus from InvenTree.status_codes import PurchaseOrderStatusGroups
def rename_company_image(instance, filename): def rename_company_image(instance, filename):
@@ -72,7 +72,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
name: Brief name of the company name: Brief name of the company
description: Longer form description description: Longer form description
website: URL for the company website website: URL for the company website
address: Postal address address: One-line string representation of primary address
phone: contact phone number phone: contact phone number
email: contact email address email: contact email address
link: Secondary URL e.g. for link to internal Wiki page link: Secondary URL e.g. for link to internal Wiki page
@@ -114,10 +114,6 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
help_text=_('Company website URL') help_text=_('Company website URL')
) )
address = models.CharField(max_length=200,
verbose_name=_('Address'),
blank=True, help_text=_('Company address'))
phone = models.CharField(max_length=50, phone = models.CharField(max_length=50,
verbose_name=_('Phone number'), verbose_name=_('Phone number'),
blank=True, help_text=_('Contact phone number')) blank=True, help_text=_('Contact phone number'))
@@ -158,6 +154,22 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
validators=[InvenTree.validators.validate_currency_code], validators=[InvenTree.validators.validate_currency_code],
) )
@property
def address(self):
"""Return the string representation for the primary address
This property exists for backwards compatibility
"""
addr = self.primary_address
return str(addr) if addr is not None else None
@property
def primary_address(self):
"""Returns address object of primary address. Parsed by serializer"""
return Address.objects.filter(company=self.id).filter(primary=True).first()
@property @property
def currency_code(self): def currency_code(self):
"""Return the currency code associated with this company. """Return the currency code associated with this company.
@@ -253,6 +265,143 @@ class Contact(MetadataMixin, models.Model):
role = models.CharField(max_length=100, blank=True) role = models.CharField(max_length=100, blank=True)
class Address(models.Model):
"""An address represents a physical location where the company is located. It is possible for a company to have multiple locations
Attributes:
company: Company link for this address
title: Human-readable name for the address
primary: True if this is the company's primary address
line1: First line of address
line2: Optional line two for address
postal_code: Postal code, city and state
country: Location country
shipping_notes: Notes for couriers transporting shipments to this address
internal_shipping_notes: Internal notes regarding shipping to this address
link: External link to additional address information
"""
def __init__(self, *args, **kwargs):
"""Custom init function"""
if 'confirm_primary' in kwargs:
self.confirm_primary = kwargs.pop('confirm_primary', None)
super().__init__(*args, **kwargs)
def __str__(self):
"""Defines string representation of address to supple a one-line to API calls"""
available_lines = [self.line1,
self.line2,
self.postal_code,
self.postal_city,
self.province,
self.country
]
populated_lines = []
for line in available_lines:
if len(line) > 0:
populated_lines.append(line)
return ", ".join(populated_lines)
class Meta:
"""Metaclass defines extra model options"""
verbose_name_plural = "Addresses"
@staticmethod
def get_api_url():
"""Return the API URL associated with the Contcat model"""
return reverse('api-address-list')
def validate_unique(self, exclude=None):
"""Ensure that only one primary address exists per company"""
super().validate_unique(exclude=exclude)
if self.primary:
# Check that no other primary address exists for this company
if Address.objects.filter(company=self.company, primary=True).exclude(pk=self.pk).exists():
raise ValidationError({'primary': _('Company already has a primary address')})
company = models.ForeignKey(Company, related_name='addresses',
on_delete=models.CASCADE,
verbose_name=_('Company'),
help_text=_('Select company'))
title = models.CharField(max_length=100,
verbose_name=_('Address title'),
help_text=_('Title describing the address entry'),
blank=False)
primary = models.BooleanField(default=False,
verbose_name=_('Primary address'),
help_text=_('Set as primary address'))
line1 = models.CharField(max_length=50,
verbose_name=_('Line 1'),
help_text=_('Address line 1'),
blank=True)
line2 = models.CharField(max_length=50,
verbose_name=_('Line 2'),
help_text=_('Address line 2'),
blank=True)
postal_code = models.CharField(max_length=10,
verbose_name=_('Postal code'),
help_text=_('Postal code'),
blank=True)
postal_city = models.CharField(max_length=50,
verbose_name=_('City/Region'),
help_text=_('Postal code city/region'),
blank=True)
province = models.CharField(max_length=50,
verbose_name=_('State/Province'),
help_text=_('State or province'),
blank=True)
country = models.CharField(max_length=50,
verbose_name=_('Country'),
help_text=_('Address country'),
blank=True)
shipping_notes = models.CharField(max_length=100,
verbose_name=_('Courier shipping notes'),
help_text=_('Notes for shipping courier'),
blank=True)
internal_shipping_notes = models.CharField(max_length=100,
verbose_name=_('Internal shipping notes'),
help_text=_('Shipping notes for internal use'),
blank=True)
link = InvenTreeURLField(blank=True,
verbose_name=_('Link'),
help_text=_('Link to address information (external)'))
@receiver(pre_save, sender=Address)
def check_primary(sender, instance, **kwargs):
"""Removes primary flag from current primary address if the to-be-saved address is marked as primary"""
if instance.company.primary_address is None:
instance.primary = True
# If confirm_primary is not present, this function does not need to do anything
if not hasattr(instance, 'confirm_primary') or \
instance.primary is False or \
instance.company.primary_address is None or \
instance.id == instance.company.primary_address.id:
return
if instance.confirm_primary is True:
adr = Address.objects.get(id=instance.company.primary_address.id)
adr.primary = False
adr.save()
class ManufacturerPart(MetadataMixin, models.Model): class ManufacturerPart(MetadataMixin, models.Model):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers. """Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
@@ -697,7 +846,7 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
def open_orders(self): def open_orders(self):
"""Return a database query for PurchaseOrder line items for this SupplierPart, limited to purchase orders that are open / outstanding.""" """Return a database query for PurchaseOrder line items for this SupplierPart, limited to purchase orders that are open / outstanding."""
return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=PurchaseOrderStatus.OPEN) return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=PurchaseOrderStatusGroups.OPEN)
def on_order(self): def on_order(self):
"""Return the total quantity of items currently on order. """Return the total quantity of items currently on order.

@@ -20,9 +20,10 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
RemoteImageMixin) RemoteImageMixin)
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart, from .models import (Address, Company, CompanyAttachment, Contact,
ManufacturerPartAttachment, ManufacturerPartParameter, ManufacturerPart, ManufacturerPartAttachment,
SupplierPart, SupplierPriceBreak) ManufacturerPartParameter, SupplierPart,
SupplierPriceBreak)
class CompanyBriefSerializer(InvenTreeModelSerializer): class CompanyBriefSerializer(InvenTreeModelSerializer):
@@ -45,6 +46,53 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
image = serializers.CharField(source='get_thumbnail_url', read_only=True) image = serializers.CharField(source='get_thumbnail_url', read_only=True)
class AddressSerializer(InvenTreeModelSerializer):
"""Serializer for the Address Model"""
class Meta:
"""Metaclass options"""
model = Address
fields = [
'pk',
'company',
'title',
'primary',
'line1',
'line2',
'postal_code',
'postal_city',
'province',
'country',
'shipping_notes',
'internal_shipping_notes',
'link',
'confirm_primary'
]
confirm_primary = serializers.BooleanField(default=False)
class AddressBriefSerializer(InvenTreeModelSerializer):
"""Serializer for Address Model (limited)"""
class Meta:
"""Metaclass options"""
model = Address
fields = [
'pk',
'line1',
'line2',
'postal_code',
'postal_city',
'province',
'country',
'shipping_notes',
'internal_shipping_notes'
]
class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for Company object (full detail)""" """Serializer for Company object (full detail)"""
@@ -73,11 +121,13 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
'parts_supplied', 'parts_supplied',
'parts_manufactured', 'parts_manufactured',
'remote_image', 'remote_image',
'address_count',
'primary_address'
] ]
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annoate the supplied queryset with aggregated information""" """Annotate the supplied queryset with aggregated information"""
# Add count of parts manufactured # Add count of parts manufactured
queryset = queryset.annotate( queryset = queryset.annotate(
parts_manufactured=SubqueryCount('manufactured_parts') parts_manufactured=SubqueryCount('manufactured_parts')
@@ -87,14 +137,21 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
parts_supplied=SubqueryCount('supplied_parts') parts_supplied=SubqueryCount('supplied_parts')
) )
queryset = queryset.annotate(
address_count=SubqueryCount('addresses')
)
return queryset return queryset
primary_address = AddressSerializer(required=False, allow_null=True, read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
image = InvenTreeImageSerializerField(required=False, allow_null=True) image = InvenTreeImageSerializerField(required=False, allow_null=True)
parts_supplied = serializers.IntegerField(read_only=True) parts_supplied = serializers.IntegerField(read_only=True)
parts_manufactured = serializers.IntegerField(read_only=True) parts_manufactured = serializers.IntegerField(read_only=True)
address_count = serializers.IntegerField(read_only=True)
currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True) currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True)
@@ -334,7 +391,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
MPN = serializers.CharField(read_only=True) MPN = serializers.CharField(read_only=True)
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True) manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', part_detail=False, read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)

@@ -26,28 +26,7 @@
<div class='panel-content'> <div class='panel-content'>
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
<div id='supplier-part-button-toolbar'> <div id='supplier-part-button-toolbar'>
<div class='button-toolbar container-fluid'> {% include "filter_list.html" with id="supplier-part" %}
<div class='btn-group' role='group'>
<div class='btn-group'>
<button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-bs-toggle="dropdown">
<span class='fas fa-tools'></span> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a class='dropdown-item' href='#' id='multi-supplier-part-order' title='{% trans "Order parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a class='dropdown-item' href='#' id='multi-supplier-part-delete' title='{% trans "Delete parts" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Parts" %}
</a></li>
{% endif %}
</ul>
</div>
{% include "filter_list.html" with id="supplier-part" %}
</div>
</div>
</div> </div>
{% endif %} {% endif %}
@@ -73,29 +52,7 @@
<div class='panel-content'> <div class='panel-content'>
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
<div id='manufacturer-part-button-toolbar'> <div id='manufacturer-part-button-toolbar'>
<div class='button-toolbar container-fluid'> {% include "filter_list.html" with id="manufacturer-part" %}
<div class='btn-group' role='group'>
<div class='btn-group' role='group'>
<button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-bs-toggle="dropdown">
<span class='fas fa-tools'></span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a class='dropdown-item' href='#' id='multi-manufacturer-part-order' title='{% trans "Order parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a class='dropdown-item' href='#' id='multi-manufacturer-part-delete' title='{% trans "Delete parts" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Parts" %}
</a></li>
{% endif %}
</ul>
</div>
{% include "filter_list.html" with id="manufacturer-part" %}
</div>
</div>
</div> </div>
{% endif %} {% endif %}
<table class='table table-striped table-condensed' id='manufacturer-part-table' data-toolbar='#manufacturer-part-button-toolbar'> <table class='table table-striped table-condensed' id='manufacturer-part-table' data-toolbar='#manufacturer-part-button-toolbar'>
@@ -128,9 +85,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='po-button-bar'> <div id='po-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> {% include "filter_list.html" with id="purchaseorder" %}
{% include "filter_list.html" with id="purchaseorder" %}
</div>
</div> </div>
<table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#po-button-bar'> <table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#po-button-bar'>
@@ -156,9 +111,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='so-button-bar'> <div id='so-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> {% include "filter_list.html" with id="salesorder" %}
{% include "filter_list.html" with id="salesorder" %}
</div>
</div> </div>
<table class='table table-striped table-condensed po-table' id='sales-order-table' data-toolbar='#so-button-bar'> <table class='table table-striped table-condensed po-table' id='sales-order-table' data-toolbar='#so-button-bar'>
@@ -174,9 +127,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='assigned-stock-button-toolbar'> <div id='assigned-stock-button-toolbar'>
<div class='btn-group' role='group'> {% include "filter_list.html" with id="customerstock" %}
{% include "filter_list.html" with id="customerstock" %}
</div>
</div> </div>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table> <table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
@@ -201,11 +152,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='table-buttons'> <div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'> {% include "filter_list.html" with id="returnorder" %}
<div class='btn-group'>
{% include "filter_list.html" with id="returnorder" %}
</div>
</div>
</div> </div>
<table class='table table-striped table-condensed' data-toolbar='#table-buttons' id='return-order-table'> <table class='table table-striped table-condensed' data-toolbar='#table-buttons' id='return-order-table'>
</table> </table>
@@ -246,15 +193,36 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='contacts-button-toolbar'> <div id='contacts-button-toolbar'>
<div class='btn-group' role='group'> {% include "filter_list.html" with id="contacts" %}
{% include "filter_list.html" with id="contacts" %}
</div>
</div> </div>
<table class='table table-striped table-condensed' id='contacts-table' data-toolbar='#contacts-button-toolbar'></table> <table class='table table-striped table-condensed' id='contacts-table' data-toolbar='#contacts-button-toolbar'></table>
</div> </div>
</div> </div>
<div class='panel panel-hidden' id='panel-company-addresses'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Company addresses" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.purchase_order.add or roles.sales_order.add %}
<button class='btn btn-success' type='button' id='new-address' title='{% trans "Add Address" %}'>
<div class='fas fa-plus-circle'></div> {% trans "Add Address" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='addresses-button-toolbar'>
{% include "filter_list.html" with id="addresses" %}
</div>
<table class='table table-striped table-condensed' id='addresses-table' data-toolbar='#addresses-button-toolbar'></table>
</div>
</div>
<div class='panel panel-hidden' id='panel-attachments'> <div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-wrap'> <div class='d-flex flex-wrap'>
@@ -309,6 +277,26 @@
}); });
}); });
// Callback function for when the 'addresses' panel is loaded
onPanelLoad('company-addresses', function(){
loadAddressTable('#addresses-table', {
params: {
company: {{ company.pk }},
},
allow_edit: {% js_bool roles.purchase_order.change %} || {% js_bool roles.sales_order.change %},
allow_delete: {% js_bool roles.purchase_order.delete %} || {% js_bool roles.sales_order.delete %},
});
$('#new-address').click(function() {
createAddress({
company: {{ company.pk }},
onSuccess: function() {
$('#addresses-table').bootstrapTable('refresh');
}
})
})
})
// Callback function when the 'notes' panel is loaded // Callback function when the 'notes' panel is loaded
onPanelLoad('company-notes', function() { onPanelLoad('company-notes', function() {
@@ -331,9 +319,6 @@
supplier_part_detail: true, supplier_part_detail: true,
location_detail: true, location_detail: true,
}, },
buttons: [
'#stock-options',
],
filterKey: "companystock", filterKey: "companystock",
}); });
}); });
@@ -453,32 +438,6 @@
} }
); );
$("#multi-manufacturer-part-delete").click(function() {
var selections = getTableData('#manufacturer-part-table');
deleteManufacturerParts(selections, {
success: function() {
$("#manufacturer-part-table").bootstrapTable('refresh');
}
});
});
$("#multi-manufacturer-part-order").click(function() {
var selections = getTableData('#manufacturer-part-table');
var parts = [];
selections.forEach(function(item) {
var part = item.part_detail;
part.manufacturer_part = item.pk;
parts.push(part);
});
orderParts(
parts,
);
});
{% endif %} {% endif %}
{% if company.is_supplier %} {% if company.is_supplier %}
@@ -507,37 +466,6 @@
}, },
} }
); );
$("#multi-supplier-part-delete").click(function() {
var selections = getTableData("#supplier-part-table");
deleteSupplierParts(selections, {
success: function() {
$('#supplier-part-table').bootstrapTable('refresh');
}
});
});
$("#multi-supplier-part-order").click(function() {
var selections = getTableData('#supplier-part-table');
var parts = [];
selections.forEach(function(item) {
var part = item.part_detail;
parts.push(part);
});
orderParts(
parts,
{
supplier: {{ company.pk }},
}
);
});
{% endif %} {% endif %}
enableSidebar('company'); enableSidebar('company');

@@ -24,11 +24,11 @@
<div class='panel-content'> <div class='panel-content'>
<div id='button-toolbar'> <div id='company-button-toolbar'>
{% include "filter_list.html" with id='company' %} {% include "filter_list.html" with id='company' %}
</div> </div>
<table class='table table-striped table-condensed' id='company-table' data-toolbar='#button-toolbar'> <table class='table table-striped table-condensed' id='company-table' data-toolbar='#company-button-toolbar'>
</table> </table>
</div> </div>

@@ -127,17 +127,7 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='supplier-button-toolbar'> <div id='supplier-button-toolbar'>
<div class='btn-group'> {% include "filter_list.html" with id='supplier-part' %}
<div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<span class='fas fa-tools'></span> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a class='dropdown-item' href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}</a></li>
</ul>
</div>
{% include "filter_list.html" with id='supplier-part' %}
</div>
</div> </div>
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#supplier-button-toolbar'> <table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#supplier-button-toolbar'>
@@ -174,17 +164,7 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='parameter-toolbar'> <div id='parameter-toolbar'>
<div class='btn-group'> {% include "filter_list.html" with id="manufacturer-part-parameters" %}
<div id='opt-dropdown' class="btn-group">
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<span class='fas fa-tools'></span> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a class='dropdown-item' href='#' id='multi-parameter-delete' title='{% trans "Delete parameters" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}</a></li>
</ul>
</div>
{% include "filter_list.html" with id="manufacturer-part-parameters" %}
</div>
</div> </div>
<table class='table table-striped table-condensed' id='parameter-table' data-toolbar='#parameter-toolbar'></table> <table class='table table-striped table-condensed' id='parameter-table' data-toolbar='#parameter-toolbar'></table>
@@ -240,26 +220,6 @@ $('#supplier-create').click(function () {
}); });
}); });
$("#supplier-part-delete").click(function() {
var selections = getTableData('#supplier-table');
deleteSupplierParts(selections, {
success: reloadSupplierPartTable,
});
});
$("#multi-parameter-delete").click(function() {
var selections = getTableData('#parameter-table');
deleteManufacturerPartParameters(selections, {
success: function() {
$('#parameter-table').bootstrapTable('refresh');
}
});
});
loadSupplierPartTable( loadSupplierPartTable(
"#supplier-table", "#supplier-table",
"{% url 'api-supplier-part-list' %}", "{% url 'api-supplier-part-list' %}",

@@ -32,6 +32,8 @@
{% endif %} {% endif %}
{% trans "Contacts" as text %} {% trans "Contacts" as text %}
{% include "sidebar_item.html" with label='company-contacts' text=text icon="fa-users" %} {% include "sidebar_item.html" with label='company-contacts' text=text icon="fa-users" %}
{% trans "Addresses" as text %}
{% include "sidebar_item.html" with label='company-addresses' text=text icon="fa-map-marked" %}
{% trans "Notes" as text %} {% trans "Notes" as text %}
{% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %} {% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %}
{% trans "Attachments" as text %} {% trans "Attachments" as text %}

@@ -234,9 +234,7 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='button-bar'> <div id='button-bar'>
<div class='btn-group' role='group'> {% include "filter_list.html" with id='purchaseorder' %}
{% include "filter_list.html" with id='purchaseorder' %}
</div>
</div> </div>
<table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#button-bar'> <table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#button-bar'>
</table> </table>
@@ -258,10 +256,8 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='price-break-toolbar' class='btn-group'> <div id='price-break-toolbar'>
<div class='btn-group' role='group'> {% include "filter_list.html" with id='supplierpricebreak' %}
{% include "filter_list.html" with id='supplierpricebreak' %}
</div>
</div> </div>
<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'> <table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>
@@ -326,7 +322,6 @@ loadStockTable($("#stock-table"), {
location_detail: true, location_detail: true,
part_detail: false, part_detail: false,
}, },
buttons: ['#stock-options'],
}); });
$("#item-create").click(function() { $("#item-create").click(function() {

@@ -6,7 +6,7 @@ from rest_framework import status
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from .models import Company, Contact, ManufacturerPart, SupplierPart from .models import Address, Company, Contact, ManufacturerPart, SupplierPart
class CompanyTest(InvenTreeAPITestCase): class CompanyTest(InvenTreeAPITestCase):
@@ -284,6 +284,138 @@ class ContactTest(InvenTreeAPITestCase):
self.get(url, expected_code=404) self.get(url, expected_code=404)
class AddressTest(InvenTreeAPITestCase):
"""Test cases for Address API endpoints"""
roles = []
@classmethod
def setUpTestData(cls):
"""Perform initialization for this test class"""
super().setUpTestData()
cls.num_companies = 3
cls.num_addr = 3
# Create some companies
companies = [
Company(
name=f"Company {idx}",
description="Some company"
) for idx in range(cls.num_companies)
]
Company.objects.bulk_create(companies)
addresses = []
# Create some contacts
for cmp in Company.objects.all():
addresses += [
Address(
company=cmp,
title=f"Address no. {idx}",
) for idx in range(cls.num_addr)
]
cls.url = reverse('api-address-list')
Address.objects.bulk_create(addresses)
def test_list(self):
"""Test listing all addresses without filtering"""
response = self.get(self.url, expected_code=200)
self.assertEqual(len(response.data), self.num_companies * self.num_addr)
def test_filter_list(self):
"""Test listing addresses filtered on company"""
company = Company.objects.first()
response = self.get(self.url, {'company': company.pk}, expected_code=200)
self.assertEqual(len(response.data), self.num_addr)
def test_create(self):
"""Test creating a new address"""
company = Company.objects.first()
self.post(self.url,
{
'company': company.pk,
'title': 'HQ'
},
expected_code=403)
self.assignRole('purchase_order.add')
self.post(self.url,
{
'company': company.pk,
'title': 'HQ'
},
expected_code=201)
def test_get(self):
"""Test that objects are properly returned from a get"""
addr = Address.objects.first()
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
response = self.get(url, expected_code=200)
self.assertEqual(response.data['pk'], addr.pk)
for key in ['title', 'line1', 'line2', 'postal_code', 'postal_city', 'province', 'country']:
self.assertIn(key, response.data)
def test_edit(self):
"""Test editing an object"""
addr = Address.objects.first()
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
self.patch(
url,
{
'title': 'Hello'
},
expected_code=403
)
self.assignRole('purchase_order.change')
self.patch(
url,
{
'title': 'World'
},
expected_code=200
)
data = self.get(url, expected_code=200).data
self.assertEqual(data['title'], 'World')
def test_delete(self):
"""Test deleting an object"""
addr = Address.objects.first()
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
self.delete(url, expected_code=403)
self.assignRole('purchase_order.delete')
self.delete(url, expected_code=204)
self.get(url, expected_code=404)
class ManufacturerTest(InvenTreeAPITestCase): class ManufacturerTest(InvenTreeAPITestCase):
"""Series of tests for the Manufacturer DRF API.""" """Series of tests for the Manufacturer DRF API."""
@@ -468,7 +600,7 @@ class SupplierPartTest(InvenTreeAPITestCase):
self.assertIsNone(sp.availability_updated) self.assertIsNone(sp.availability_updated)
self.assertEqual(sp.available, 0) self.assertEqual(sp.available, 0)
# Now, *update* the availabile quantity via the API # Now, *update* the available quantity via the API
self.patch( self.patch(
reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}), reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}),
{ {

@@ -48,7 +48,8 @@ class TestManufacturerField(MigratorTestCase):
# Create an initial part # Create an initial part
part = Part.objects.create( part = Part.objects.create(
name='Screw', name='Screw',
description='A single screw' description='A single screw',
level=0, tree_id=0, lft=0, rght=0
) )
# Create a company to act as the supplier # Create a company to act as the supplier
@@ -279,6 +280,47 @@ class TestCurrencyMigration(MigratorTestCase):
self.assertIsNotNone(pb.price) self.assertIsNotNone(pb.price)
class TestAddressMigration(MigratorTestCase):
"""Test moving address data into Address model"""
migrate_from = ('company', '0063_auto_20230502_1956')
migrate_to = ('company', '0064_move_address_field_to_address_model')
# Setting up string values for re-use
short_l1 = 'Less than 50 characters long address'
long_l1 = 'More than 50 characters long address testing line '
l2 = 'splitting functionality'
def prepare(self):
"""Set up some companies with addresses"""
Company = self.old_state.apps.get_model('company', 'company')
Company.objects.create(name='Company 1', address=self.short_l1)
Company.objects.create(name='Company 2', address=self.long_l1 + self.l2)
def test_address_migration(self):
"""Test database state after applying the migration"""
Address = self.new_state.apps.get_model('company', 'address')
Company = self.new_state.apps.get_model('company', 'company')
c1 = Company.objects.filter(name='Company 1').first()
c2 = Company.objects.filter(name='Company 2').first()
self.assertEqual(len(Address.objects.all()), 2)
a1 = Address.objects.filter(company=c1.pk).first()
a2 = Address.objects.filter(company=c2.pk).first()
self.assertEqual(a1.line1, self.short_l1)
self.assertEqual(a1.line2, "")
self.assertEqual(a2.line1, self.long_l1)
self.assertEqual(a2.line2, self.l2)
self.assertEqual(c1.address, '')
self.assertEqual(c2.address, '')
class TestSupplierPartQuantity(MigratorTestCase): class TestSupplierPartQuantity(MigratorTestCase):
"""Test that the supplier part quantity is correctly migrated.""" """Test that the supplier part quantity is correctly migrated."""

@@ -4,11 +4,12 @@ import os
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction
from django.test import TestCase from django.test import TestCase
from part.models import Part from part.models import Part
from .models import (Company, Contact, ManufacturerPart, SupplierPart, from .models import (Address, Company, Contact, ManufacturerPart, SupplierPart,
rename_company_image) rename_company_image)
@@ -35,7 +36,6 @@ class CompanySimpleTest(TestCase):
Company.objects.create(name='ABC Co.', Company.objects.create(name='ABC Co.',
description='Seller of ABC products', description='Seller of ABC products',
website='www.abc-sales.com', website='www.abc-sales.com',
address='123 Sales St.',
is_customer=False, is_customer=False,
is_supplier=True) is_supplier=True)
@@ -174,6 +174,88 @@ class ContactSimpleTest(TestCase):
self.assertEqual(Contact.objects.count(), 0) self.assertEqual(Contact.objects.count(), 0)
class AddressTest(TestCase):
"""Unit tests for the Address model"""
def setUp(self):
"""Initialization for the tests in this class"""
# Create a simple company
self.c = Company.objects.create(name='Test Corp.', description='We make stuff good')
def test_create(self):
"""Test that object creation with only company supplied is successful"""
Address.objects.create(company=self.c)
self.assertEqual(Address.objects.count(), 1)
def test_delete(self):
"""Test Address deletion"""
addr = Address.objects.create(company=self.c)
addr.delete()
self.assertEqual(Address.objects.count(), 0)
def test_primary_constraint(self):
"""Test that there can only be one company-'primary=true' pair"""
c2 = Company.objects.create(name='Test Corp2.', description='We make stuff good')
Address.objects.create(company=self.c, primary=True)
Address.objects.create(company=self.c, primary=False)
self.assertEqual(Address.objects.count(), 2)
# Testing the constraint itself
# Intentionally throwing exceptions breaks unit tests unless performed in an atomic block
with transaction.atomic():
with self.assertRaises(ValidationError):
addr = Address(company=self.c, primary=True, confirm_primary=False)
addr.validate_unique()
Address.objects.create(company=c2, primary=True, line1="Hellothere", line2="generalkenobi")
with transaction.atomic():
with self.assertRaises(ValidationError):
addr = Address(company=c2, primary=True, confirm_primary=False)
addr.validate_unique()
self.assertEqual(Address.objects.count(), 3)
def test_first_address_is_primary(self):
"""Test that first address related to company is always set to primary"""
addr = Address.objects.create(company=self.c)
self.assertTrue(addr.primary)
# Create another address, which should error out if primary is not set to False
with self.assertRaises(ValidationError):
addr = Address(company=self.c, primary=True)
addr.validate_unique()
def test_model_str(self):
"""Test value of __str__"""
t = "Test address"
l1 = "Busy street 56"
l2 = "Red building"
pcd = "12345"
pct = "City"
pv = "Province"
cn = "COUNTRY"
addr = Address.objects.create(company=self.c,
title=t,
line1=l1,
line2=l2,
postal_code=pcd,
postal_city=pct,
province=pv,
country=cn)
self.assertEqual(str(addr), f'{l1}, {l2}, {pcd}, {pct}, {pv}, {cn}')
addr2 = Address.objects.create(company=self.c,
title=t,
line1=l1,
postal_code=pcd)
self.assertEqual(str(addr2), f'{l1}, {pcd}')
class ManufacturerPartSimpleTest(TestCase): class ManufacturerPartSimpleTest(TestCase):
"""Unit tests for the ManufacturerPart model""" """Unit tests for the ManufacturerPart model"""

@@ -183,6 +183,11 @@ login_default_protocol: http
remote_login_enabled: False remote_login_enabled: False
remote_login_header: HTTP_REMOTE_USER remote_login_header: HTTP_REMOTE_USER
# JWT tokens
# JWT can be used optionally to authenticate users. Turned off by default.
# Alternatively, use the environment variable INVENTREE_USE_JWT
# use_jwt: True
# Logout redirect configuration # Logout redirect configuration
# This setting may be required if using remote / proxy login to redirect requests # This setting may be required if using remote / proxy login to redirect requests
# during the logout process (default is 'index'). Please read the docs for more details # during the logout process (default is 'index'). Please read the docs for more details
@@ -228,3 +233,11 @@ remote_login_header: HTTP_REMOTE_USER
# splash: splash_screen.jpg # splash: splash_screen.jpg
# hide_admin_link: true # hide_admin_link: true
# hide_password_reset: true # hide_password_reset: true
# Custom flags
# InvenTree uses django-flags; read more in their docs at https://cfpb.github.io/django-flags/conditions/
# Use environment variable INVENTREE_FLAGS or the settings below
# flags:
# MY_FLAG:
# - condition: 'parameter'
# value: 'my_flag_param1'

@@ -0,0 +1,4 @@
"""The generic module provides high-level functionality that is used in multiple places.
The generic module is split into sub-modules. Each sub-module provides a specific set of functionality. Each sub-module should be 100% tested within the sub-module.
"""

@@ -0,0 +1,13 @@
"""States are used to track the logical state of an object.
The logic value of a state is stored in the database as an integer. The logic value is used for business logic and should not be easily changed therefore.
There is a rendered state for each state value. The rendered state is used for display purposes and can be changed easily.
States can be extended with custom options for each InvenTree instance - those options are stored in the database and need to link back to state values.
"""
from .states import StatusCode
__all__ = [
StatusCode,
]

@@ -0,0 +1,54 @@
"""Generic implementation of status api functions for InvenTree models."""
import inspect
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from .states import StatusCode
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 inspect.isclass(status_class):
raise NotImplementedError("`status_class` not a class")
if not issubclass(status_class, StatusCode):
raise NotImplementedError("`status_class` not a valid StatusCode class")
data = {
'class': status_class.__name__,
'values': status_class.dict(),
}
return Response(data)

@@ -0,0 +1,170 @@
"""Generic implementation of status for InvenTree models."""
import enum
import re
class BaseEnum(enum.IntEnum):
"""An `Enum` capabile of having its members have docstrings.
Based on https://stackoverflow.com/questions/19330460/how-do-i-put-docstrings-on-enums
"""
def __new__(cls, *args):
"""Assign values on creation."""
obj = object.__new__(cls)
obj._value_ = args[0]
return obj
def __eq__(self, obj):
"""Override equality operator to allow comparison with int."""
if type(self) == type(obj):
return super().__eq__(obj)
return self.value == obj
def __ne__(self, obj):
"""Override inequality operator to allow comparison with int."""
if type(self) == type(obj):
return super().__ne__(obj)
return self.value != obj
class StatusCode(BaseEnum):
"""Base class for representing a set of StatusCodes.
Use enum syntax to define the status codes, e.g.
```python
PENDING = 10, _("Pending"), 'secondary'
```
The values of the status can be accessed with `StatusCode.PENDING.value`.
Additionally there are helpers to access all additional attributes `text`, `label`, `color`.
"""
def __new__(cls, *args):
"""Define object out of args."""
obj = int.__new__(cls)
obj._value_ = args[0]
# Normal item definition
if len(args) == 1:
obj.label = args[0]
obj.color = 'secondary'
else:
obj.label = args[1]
obj.color = args[2] if len(args) > 2 else 'secondary'
return obj
@classmethod
def _is_element(cls, d):
"""Check if the supplied value is a valid status code."""
if d.startswith('_'):
return False
if d != d.upper():
return False
value = getattr(cls, d, None)
if value is None:
return False
if callable(value):
return False
if type(value.value) != int:
return False
return True
@classmethod
def values(cls, key=None):
"""Return a dict representation containing all required information"""
elements = [itm for itm in cls if cls._is_element(itm.name)]
if key is None:
return elements
ret = [itm for itm in elements if itm.value == key]
if ret:
return ret[0]
return None
@classmethod
def render(cls, key, large=False):
"""Render the value as a HTML label."""
# If the key cannot be found, pass it back
item = cls.values(key)
if item is None:
return key
return f"<span class='badge rounded-pill bg-{item.color}'>{item.label}</span>"
@classmethod
def tag(cls):
"""Return tag for this status code."""
# Return the tag if it is defined
if hasattr(cls, '_TAG') and bool(cls._TAG):
return cls._TAG.value
# Try to find a default tag
# Remove `Status` from the class name
ref_name = cls.__name__.removesuffix('Status')
# Convert to snake case
return re.sub(r'(?<!^)(?=[A-Z])', '_', ref_name).lower()
@classmethod
def items(cls):
"""All status code items."""
return [(x.value, x.label) for x in cls.values()]
@classmethod
def keys(cls):
"""All status code keys."""
return [x.value for x in cls.values()]
@classmethod
def labels(cls):
"""All status code labels."""
return [x.label for x in cls.values()]
@classmethod
def names(cls):
"""Return a map of all 'names' of status codes in this class."""
return {x.name: x.value for x in cls.values()}
@classmethod
def text(cls, key):
"""Text for supplied status code."""
filtered = cls.values(key)
if filtered is None:
return key
return filtered.label
@classmethod
def label(cls, key):
"""Return the status code label associated with the provided value."""
filtered = cls.values(key)
if filtered is None:
return key
return filtered.label
@classmethod
def dict(cls, key=None):
"""Return a dict representation containing all required information"""
return {x.name: {
'color': x.color,
'key': x.value,
'label': x.label,
'name': x.name,
} for x in cls.values(key)}
@classmethod
def list(cls):
"""Return the StatusCode options as a list of mapped key / value items."""
return list(cls.dict().values())
@classmethod
def template_context(cls):
"""Return a dict representation containing all required information for templates."""
ret = {x.name: x.value for x in cls.values()}
ret['list'] = cls.list()
return ret

@@ -0,0 +1,17 @@
"""Provide templates for the various model status codes."""
from django.utils.safestring import mark_safe
from generic.templatetags.generic import register
from InvenTree.helpers import inheritors
from .states import StatusCode
@register.simple_tag
def status_label(typ: str, key: int, *args, **kwargs):
"""Render a status label."""
state = {cls.tag(): cls for cls in inheritors(StatusCode)}.get(typ, None)
if state:
return mark_safe(state.render(key, large=kwargs.get('large', False)))
raise ValueError(f"Unknown status type '{typ}'")

@@ -0,0 +1,110 @@
"""Tests for the generic states module."""
from django.test.client import RequestFactory
from django.utils.translation import gettext_lazy as _
from rest_framework.test import force_authenticate
from InvenTree.unit_test import InvenTreeTestCase
from .api import StatusView
from .states import StatusCode
class GeneralStatus(StatusCode):
"""Defines a set of status codes for tests."""
PENDING = 10, _("Pending"), 'secondary'
PLACED = 20, _("Placed"), 'primary'
COMPLETE = 30, _("Complete"), 'success'
ABC = None # This should be ignored
_DEF = None # This should be ignored
jkl = None # This should be ignored
def GHI(self): # This should be ignored
"""A invalid function"""
pass
class GeneralStateTest(InvenTreeTestCase):
"""Test that the StatusCode class works."""
def test_code_definition(self):
"""Test that the status code class has been defined correctly."""
self.assertEqual(GeneralStatus.PENDING, 10)
self.assertEqual(GeneralStatus.PLACED, 20)
self.assertEqual(GeneralStatus.COMPLETE, 30)
def test_code_functions(self):
"""Test that the status code class functions work correctly"""
# render
self.assertEqual(GeneralStatus.render(10), "<span class='badge rounded-pill bg-secondary'>Pending</span>")
self.assertEqual(GeneralStatus.render(20), "<span class='badge rounded-pill bg-primary'>Placed</span>")
# render with invalid key
self.assertEqual(GeneralStatus.render(100), 100)
# list
self.assertEqual(GeneralStatus.list(), [{'color': 'secondary', 'key': 10, 'label': 'Pending', 'name': 'PENDING'}, {'color': 'primary', 'key': 20, 'label': 'Placed', 'name': 'PLACED'}, {'color': 'success', 'key': 30, 'label': 'Complete', 'name': 'COMPLETE'}])
# text
self.assertEqual(GeneralStatus.text(10), 'Pending')
self.assertEqual(GeneralStatus.text(20), 'Placed')
# items
self.assertEqual(list(GeneralStatus.items()), [(10, 'Pending'), (20, 'Placed'), (30, 'Complete')])
# keys
self.assertEqual(list(GeneralStatus.keys()), ([10, 20, 30]))
# labels
self.assertEqual(list(GeneralStatus.labels()), ['Pending', 'Placed', 'Complete'])
# names
self.assertEqual(GeneralStatus.names(), {'PENDING': 10, 'PLACED': 20, 'COMPLETE': 30})
# dict
self.assertEqual(GeneralStatus.dict(), {
'PENDING': {'key': 10, 'name': 'PENDING', 'label': 'Pending', 'color': 'secondary'},
'PLACED': {'key': 20, 'name': 'PLACED', 'label': 'Placed', 'color': 'primary'},
'COMPLETE': {'key': 30, 'name': 'COMPLETE', 'label': 'Complete', 'color': 'success'},
})
# label
self.assertEqual(GeneralStatus.label(10), 'Pending')
def test_tag_function(self):
"""Test that the status code tag functions."""
from .tags import status_label
self.assertEqual(status_label('general', 10), "<span class='badge rounded-pill bg-secondary'>Pending</span>")
# invalid type
with self.assertRaises(ValueError) as e:
status_label('invalid', 10)
self.assertEqual(str(e.exception), "Unknown status type 'invalid'")
# Test non-existent key
self.assertEqual(status_label('general', 100), '100')
def test_api(self):
"""Test StatusView API view."""
view = StatusView.as_view()
rqst = RequestFactory().get('status/',)
force_authenticate(rqst, user=self.user)
# Correct call
resp = view(rqst, **{StatusView.MODEL_REF: GeneralStatus})
self.assertEqual(resp.data, {'class': 'GeneralStatus', 'values': {'COMPLETE': {'key': 30, 'name': 'COMPLETE', 'label': 'Complete', 'color': 'success'}, 'PENDING': {'key': 10, 'name': 'PENDING', 'label': 'Pending', 'color': 'secondary'}, 'PLACED': {'key': 20, 'name': 'PLACED', 'label': 'Placed', 'color': 'primary'}}})
# No status defined
resp = view(rqst, **{StatusView.MODEL_REF: None})
self.assertEqual(resp.status_code, 400)
self.assertEqual(str(resp.rendered_content, 'utf-8'), '["StatusView view called without \'statusmodel\' parameter"]')
# Invalid call - not a class
with self.assertRaises(NotImplementedError) as e:
resp = view(rqst, **{StatusView.MODEL_REF: 'invalid'})
self.assertEqual(str(e.exception), "`status_class` not a class")
# Invalid call - not the right class
with self.assertRaises(NotImplementedError) as e:
resp = view(rqst, **{StatusView.MODEL_REF: object})
self.assertEqual(str(e.exception), "`status_class` not a valid StatusCode class")

@@ -0,0 +1 @@
"""Template tags for generic *things*."""

@@ -0,0 +1,10 @@
"""Template tags for generic *things*."""
from django import template
register = template.Library()
from generic.states.tags import status_label # noqa: E402
__all__ = [
status_label,
]

@@ -13,3 +13,4 @@ class LabelAdmin(admin.ModelAdmin):
admin.site.register(label.models.StockItemLabel, LabelAdmin) admin.site.register(label.models.StockItemLabel, LabelAdmin)
admin.site.register(label.models.StockLocationLabel, LabelAdmin) admin.site.register(label.models.StockLocationLabel, LabelAdmin)
admin.site.register(label.models.PartLabel, LabelAdmin) admin.site.register(label.models.PartLabel, LabelAdmin)
admin.site.register(label.models.BuildLineLabel, LabelAdmin)

@@ -10,6 +10,7 @@ from django.views.decorators.cache import cache_page, never_cache
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
import build.models
import common.models import common.models
import InvenTree.helpers import InvenTree.helpers
import label.models import label.models
@@ -368,6 +369,31 @@ class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI):
pass pass
class BuildLineLabelMixin:
"""Mixin class for BuildLineLabel endpoints"""
queryset = label.models.BuildLineLabel.objects.all()
serializer_class = label.serializers.BuildLineLabelSerializer
ITEM_MODEL = build.models.BuildLine
ITEM_KEY = 'line'
class BuildLineLabelList(BuildLineLabelMixin, LabelListView):
"""API endpoint for viewing a list of BuildLineLabel objects"""
pass
class BuildLineLabelDetail(BuildLineLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single BuildLineLabel object"""
pass
class BuildLineLabelPrint(BuildLineLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a BuildLineLabel object"""
pass
label_api_urls = [ label_api_urls = [
# Stock item labels # Stock item labels
@@ -408,4 +434,17 @@ label_api_urls = [
# List view # List view
re_path(r'^.*$', PartLabelList.as_view(), name='api-part-label-list'), re_path(r'^.*$', PartLabelList.as_view(), name='api-part-label-list'),
])), ])),
# BuildLine labels
re_path(r'^buildline/', include([
# Detail views
path(r'<int:pk>/', include([
re_path(r'^print/', BuildLineLabelPrint.as_view(), name='api-buildline-label-print'),
re_path(r'^metadata/', MetadataView.as_view(), {'model': label.models.BuildLineLabel}, name='api-buildline-label-metadata'),
re_path(r'^.*$', BuildLineLabelDetail.as_view(), name='api-buildline-label-detail'),
])),
# List view
re_path(r'^.*$', BuildLineLabelList.as_view(), name='api-buildline-label-list'),
])),
] ]

@@ -40,7 +40,7 @@ class LabelConfig(AppConfig):
if not isPluginRegistryLoaded() or not isInMainThread(): if not isPluginRegistryLoaded() or not isInMainThread():
return return
if canAppAccessDatabase(): if canAppAccessDatabase(allow_test=False):
try: try:
self.create_labels() # pragma: no cover self.create_labels() # pragma: no cover
@@ -51,12 +51,12 @@ class LabelConfig(AppConfig):
def create_labels(self): def create_labels(self):
"""Create all default templates.""" """Create all default templates."""
# Test if models are ready # Test if models are ready
from .models import PartLabel, StockItemLabel, StockLocationLabel import label.models
assert bool(StockLocationLabel is not None) assert bool(label.models.StockLocationLabel is not None)
# Create the categories # Create the categories
self.create_labels_category( self.create_labels_category(
StockItemLabel, label.models.StockItemLabel,
'stockitem', 'stockitem',
[ [
{ {
@@ -70,7 +70,7 @@ class LabelConfig(AppConfig):
) )
self.create_labels_category( self.create_labels_category(
StockLocationLabel, label.models.StockLocationLabel,
'stocklocation', 'stocklocation',
[ [
{ {
@@ -91,7 +91,7 @@ class LabelConfig(AppConfig):
) )
self.create_labels_category( self.create_labels_category(
PartLabel, label.models.PartLabel,
'part', 'part',
[ [
{ {
@@ -111,6 +111,20 @@ class LabelConfig(AppConfig):
] ]
) )
self.create_labels_category(
label.models.BuildLineLabel,
'buildline',
[
{
'file': 'buildline_label.html',
'name': 'Build Line Label',
'description': 'Example build line label',
'width': 125,
'height': 48,
},
]
)
def create_labels_category(self, model, ref_name, labels): def create_labels_category(self, model, ref_name, labels):
"""Create folder and database entries for the default templates, if they do not already exist.""" """Create folder and database entries for the default templates, if they do not already exist."""
# Create root dir for templates # Create root dir for templates
@@ -173,13 +187,15 @@ class LabelConfig(AppConfig):
logger.info(f"Creating entry for {model} '{label['name']}'") logger.info(f"Creating entry for {model} '{label['name']}'")
model.objects.create( try:
name=label['name'], model.objects.create(
description=label['description'], name=label['name'],
label=filename, description=label['description'],
filters='', label=filename,
enabled=True, filters='',
width=label['width'], enabled=True,
height=label['height'], width=label['width'],
) height=label['height'],
return )
except Exception:
logger.warning(f"Failed to create label '{label['name']}'")

@@ -0,0 +1,33 @@
# Generated by Django 3.2.19 on 2023-06-13 11:10
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0009_auto_20230317_0816'),
]
operations = [
migrations.CreateModel(
name='BuildLineLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('name', models.CharField(help_text='Label name', max_length=100, verbose_name='Name')),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')),
('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')),
('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')),
('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_build_line_filters], verbose_name='Filters')),
],
options={
'abstract': False,
},
),
]

@@ -0,0 +1,29 @@
# Generated by Django 3.2.19 on 2023-06-23 21:58
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0010_buildlinelabel'),
]
operations = [
migrations.AlterField(
model_name='partlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_stock_location_filters], verbose_name='Filters'),
),
]

@@ -13,6 +13,7 @@ from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import build.models
import part.models import part.models
import stock.models import stock.models
from InvenTree.helpers import normalize, validateFilterString from InvenTree.helpers import normalize, validateFilterString
@@ -59,6 +60,13 @@ def validate_part_filters(filters):
return filters return filters
def validate_build_line_filters(filters):
"""Validate query filters for the BuildLine model"""
filters = validateFilterString(filters, model=build.models.BuildLine)
return filters
class WeasyprintLabelMixin(WeasyTemplateResponseMixin): class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
"""Class for rendering a label to a PDF.""" """Class for rendering a label to a PDF."""
@@ -239,7 +247,7 @@ class StockItemLabel(LabelTemplate):
filters = models.CharField( filters = models.CharField(
blank=True, max_length=250, blank=True, max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs),'), help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'), verbose_name=_('Filters'),
validators=[ validators=[
validate_stock_item_filters validate_stock_item_filters
@@ -280,7 +288,7 @@ class StockLocationLabel(LabelTemplate):
filters = models.CharField( filters = models.CharField(
blank=True, max_length=250, blank=True, max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs'), help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'), verbose_name=_('Filters'),
validators=[ validators=[
validate_stock_location_filters] validate_stock_location_filters]
@@ -308,7 +316,7 @@ class PartLabel(LabelTemplate):
filters = models.CharField( filters = models.CharField(
blank=True, max_length=250, blank=True, max_length=250,
help_text=_('Part query filters (comma-separated value of key=value pairs)'), help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'), verbose_name=_('Filters'),
validators=[ validators=[
validate_part_filters validate_part_filters
@@ -330,3 +338,38 @@ class PartLabel(LabelTemplate):
'qr_url': request.build_absolute_uri(part.get_absolute_url()), 'qr_url': request.build_absolute_uri(part.get_absolute_url()),
'parameters': part.parameters_map(), 'parameters': part.parameters_map(),
} }
class BuildLineLabel(LabelTemplate):
"""Template for printing labels against BuildLine objects"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the BuildLineLabel model"""
return reverse('api-buildline-label-list')
SUBDIR = 'buildline'
filters = models.CharField(
blank=True, max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'),
validators=[
validate_build_line_filters
]
)
def get_context_data(self, request):
"""Generate context data for each provided BuildLine object."""
build_line = self.object_to_print
return {
'build_line': build_line,
'build': build_line.build,
'bom_item': build_line.bom_item,
'part': build_line.bom_item.sub_part,
'quantity': build_line.quantity,
'allocated_quantity': build_line.allocated_quantity,
'allocations': build_line.allocations,
}

@@ -52,3 +52,13 @@ class PartLabelSerializer(LabelSerializerBase):
model = label.models.PartLabel model = label.models.PartLabel
fields = LabelSerializerBase.label_fields() fields = LabelSerializerBase.label_fields()
class BuildLineLabelSerializer(LabelSerializerBase):
"""Serializes a BuildLineLabel object"""
class Meta:
"""Metaclass options."""
model = label.models.BuildLineLabel
fields = LabelSerializerBase.label_fields()

@@ -0,0 +1,3 @@
{% extends "label/buildline/buildline_label_base.html" %}
<!-- Refer to the buildline_label_base template for further information -->

@@ -0,0 +1,74 @@
{% extends "label/label_base.html" %}
{% load barcode report %}
{% load inventree_extras %}
<!--
This is an example template for printing labels against BuildLine objects.
Refer to the documentation for a full list of available template variables.
-->
{% block style %}
{{ block.super }}
.label {
margin: 1mm;
}
.qr {
height: 28mm;
width: 28mm;
position: relative;
top: 0mm;
right: 0mm;
float: right;
}
.label-table {
width: 100%;
border-collapse: collapse;
border: 1pt solid black;
}
.label-table tr {
width: 100%;
border-bottom: 1pt solid black;
padding: 2.5mm;
}
.label-table td {
padding: 3mm;
}
{% endblock style %}
{% block content %}
<div class='label'>
<table class='label-table'>
<tr>
<td>
<b>Build Order:</b> {{ build.reference }}<br>
<b>Build Qty:</b> {% decimal build.quantity %}<br>
</td>
<td>
<img class='qr' alt='build qr' src='{% qrcode build.barcode %}'>
</td>
</tr>
<tr>
<td>
<b>Part:</b> {{ part.name }}<br>
{% if part.IPN %}
<b>IPN:</b> {{ part.IPN }}<br>
{% endif %}
<b>Qty / Unit:</b> {% decimal bom_item.quantity %} {% if part.units %}[{{ part.units }}]{% endif %}<br>
<b>Qty Total:</b> {% decimal quantity %} {% if part.units %}[{{ part.units }}]{% endif %}
</td>
<td>
<img class='qr' alt='part qr' src='{% qrcode part.barcode %}'>
</td>
</tr>
</table>
</div>
{% endblock content %}

@@ -28,7 +28,7 @@
{% block content %} {% block content %}
<img class='qr' alt="{% trans 'QC Code' %}" src='{% qrcode qr_data %}'> <img class='qr' alt="{% trans 'QR Code' %}" src='{% qrcode qr_data %}'>
<div class='part'> <div class='part'>
{{ part.full_name }} {{ part.full_name }}

@@ -18,5 +18,5 @@
{% block content %} {% block content %}
<img class='qr' alt="{% trans 'QC Code' %}" src='{% qrcode qr_data %}'> <img class='qr' alt="{% trans 'QR Code' %}" src='{% qrcode qr_data %}'>
{% endblock content %} {% endblock content %}

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