mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13:05:42 +00:00
Merge remote-tracking branch 'upstream/master' into add-ready-checks
This commit is contained in:
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@ -1,3 +1,4 @@
|
||||
github: inventree
|
||||
ko_fi: inventree
|
||||
patreon: inventree
|
||||
custom: [paypal.me/inventree]
|
||||
|
2
.github/actions/migration/action.yaml
vendored
2
.github/actions/migration/action.yaml
vendored
@ -1,5 +1,5 @@
|
||||
name: 'Migration test'
|
||||
description: 'Run migration test sequenze'
|
||||
description: 'Run migration test sequence'
|
||||
author: 'InvenTree'
|
||||
|
||||
runs:
|
||||
|
6
.github/workflows/docker.yaml
vendored
6
.github/workflows/docker.yaml
vendored
@ -29,9 +29,6 @@ jobs:
|
||||
|
||||
# Build the docker image
|
||||
build:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@ -81,6 +78,7 @@ jobs:
|
||||
run: |
|
||||
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> docker.dev.env
|
||||
docker-compose run inventree-dev-server invoke test --disable-pty
|
||||
docker-compose run inventree-dev-server invoke test --migrations --disable-pty
|
||||
docker-compose down
|
||||
- name: Set up QEMU
|
||||
if: github.event_name != 'pull_request'
|
||||
@ -123,6 +121,8 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: true
|
||||
provenance: false
|
||||
target: production
|
||||
tags: ${{ env.docker_tags }}
|
||||
build-args: |
|
||||
|
94
.github/workflows/qc_checks.yaml
vendored
94
.github/workflows/qc_checks.yaml
vendored
@ -28,6 +28,7 @@ jobs:
|
||||
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
migrations: ${{ steps.filter.outputs.migrations }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
@ -39,6 +40,9 @@ jobs:
|
||||
- 'InvenTree/**'
|
||||
- 'requirements.txt'
|
||||
- 'requirements-dev.txt'
|
||||
migrations:
|
||||
- '**/migrations/**'
|
||||
- '.github/workflows**'
|
||||
|
||||
pep_style:
|
||||
name: Style [Python]
|
||||
@ -99,7 +103,7 @@ jobs:
|
||||
python3 ci/version_check.py
|
||||
|
||||
mkdocs:
|
||||
name: Style [documentation]
|
||||
name: Style [Documentation]
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: paths-filter
|
||||
@ -204,7 +208,7 @@ jobs:
|
||||
- name: Check Migration Files
|
||||
run: python3 ci/check_migration_files.py
|
||||
- name: Coverage Tests
|
||||
run: invoke coverage
|
||||
run: invoke test --coverage
|
||||
- name: Upload Coverage Report
|
||||
uses: coverallsapp/github-action@v2
|
||||
with:
|
||||
@ -297,3 +301,89 @@ jobs:
|
||||
run: invoke test
|
||||
- name: Data Export Test
|
||||
uses: ./.github/actions/migration
|
||||
|
||||
migration-tests:
|
||||
name: Run Migration Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: paths-filter
|
||||
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
|
||||
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: django.db.backends.postgresql
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_DB_USER: inventree
|
||||
INVENTREE_DB_PASSWORD: password
|
||||
INVENTREE_DB_HOST: '127.0.0.1'
|
||||
INVENTREE_DB_PORT: 5432
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_USER: inventree
|
||||
POSTGRES_PASSWORD: password
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils libpq-dev
|
||||
pip-dependency: psycopg2
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Run Tests
|
||||
run: invoke test --migrations --report
|
||||
|
||||
migrations-checks:
|
||||
name: Run Database Migrations
|
||||
runs-on: ubuntu-latest
|
||||
needs: paths-filter
|
||||
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
|
||||
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
name: Checkout Code
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
install: true
|
||||
- name: Fetch Database
|
||||
run: git clone --depth 1 https://github.com/inventree/test-db ./test-db
|
||||
|
||||
- name: Latest Database
|
||||
run: |
|
||||
cp test-db/latest.sqlite3 /home/runner/work/InvenTree/db.sqlite3
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
- name: 0.10.0 Database
|
||||
run: |
|
||||
rm /home/runner/work/InvenTree/db.sqlite3
|
||||
cp test-db/stable_0.10.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
- name: 0.11.0 Database
|
||||
run: |
|
||||
rm /home/runner/work/InvenTree/db.sqlite3
|
||||
cp test-db/stable_0.11.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
- 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_CONFIG_FILE=/opt/inventree/config.yaml
|
||||
after_install: contrib/packager.io/postinstall.sh
|
||||
before:
|
||||
- contrib/packager.io/before.sh
|
||||
dependencies:
|
||||
- curl
|
||||
- python3
|
||||
@ -33,3 +35,4 @@ dependencies:
|
||||
targets:
|
||||
ubuntu-20.04: true
|
||||
debian-11: true
|
||||
debian-12: true
|
||||
|
@ -41,7 +41,7 @@ repos:
|
||||
args: [requirements.in, -o, requirements.txt]
|
||||
files: ^requirements\.(in|txt)$
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.29.0
|
||||
rev: v1.30.2
|
||||
hooks:
|
||||
- id: djlint-django
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
|
@ -19,7 +19,7 @@ pip install invoke && invoke setup-dev --tests
|
||||
```bash
|
||||
git clone https://github.com/inventree/InvenTree.git && cd InvenTree
|
||||
docker compose run inventree-dev-server invoke install
|
||||
docker compose run inventree-dev-server invoke setup-test
|
||||
docker compose run inventree-dev-server invoke setup-test --dev
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@ -33,7 +33,7 @@ Run the following command to set up all toolsets for development.
|
||||
invoke setup-dev
|
||||
```
|
||||
|
||||
*We recommend you run this command before starting to contribute. This will install and set up `pre-commit` to run some checks before each commit and help reduce 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
|
||||
|
||||
@ -135,10 +135,27 @@ To run only partial tests, for example for a module use:
|
||||
invoke test --runtest order
|
||||
```
|
||||
|
||||
To see all the available options:
|
||||
|
||||
```
|
||||
invoke test --help
|
||||
```
|
||||
|
||||
## 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.
|
||||
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`.
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
|
85
Dockerfile
85
Dockerfile
@ -9,7 +9,7 @@
|
||||
# - Runs InvenTree web server under django development 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
|
||||
ARG commit_hash=""
|
||||
@ -17,6 +17,8 @@ ARG commit_date=""
|
||||
ARG commit_tag=""
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
|
||||
ENV INVOKE_RUN_SHELL="/bin/ash"
|
||||
|
||||
ENV INVENTREE_LOG_LEVEL="WARNING"
|
||||
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-ref=${commit_tag}
|
||||
|
||||
# RUN apt-get upgrade && apt-get update
|
||||
RUN apt-get update
|
||||
|
||||
# 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 \
|
||||
RUN apk add --no-cache \
|
||||
git gettext py-cryptography \
|
||||
# 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
|
||||
sqlite3 \
|
||||
sqlite \
|
||||
# PostgreSQL support
|
||||
libpq-dev postgresql-client \
|
||||
postgresql-libs postgresql-client \
|
||||
# MySQL / MariaDB support
|
||||
default-libmysqlclient-dev mariadb-client && \
|
||||
apt-get autoclean && apt-get autoremove
|
||||
mariadb-connector-c-dev mariadb-client && \
|
||||
# fonts
|
||||
apk --update --upgrade --no-cache add fontconfig ttf-freefont font-noto terminus-font && fc-cache -f
|
||||
|
||||
# Update pip
|
||||
RUN pip install --upgrade pip
|
||||
EXPOSE 8000
|
||||
|
||||
# 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
|
||||
# Ref: https://github.com/inventree/InvenTree/pull/4598
|
||||
RUN \
|
||||
if [ `dpkg --print-architecture` = "armhf" ]; then \
|
||||
RUN if [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
|
||||
# Install required base-level python packages
|
||||
COPY ./docker/requirements.txt base_requirements.txt
|
||||
RUN pip install --disable-pip-version-check -U -r base_requirements.txt
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
gcc g++ musl-dev openssl-dev libffi-dev cargo python3-dev \
|
||||
# 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:
|
||||
# - Copies required files from local directory
|
||||
# - Installs required python packages from requirements.txt
|
||||
# - Starts a gunicorn webserver
|
||||
|
||||
FROM inventree_base as production
|
||||
|
||||
ENV INVENTREE_DEBUG=False
|
||||
@ -98,33 +110,14 @@ ENV INVENTREE_COMMIT_HASH="${commit_hash}"
|
||||
ENV INVENTREE_COMMIT_DATE="${commit_date}"
|
||||
|
||||
# Copy source code
|
||||
COPY InvenTree ${INVENTREE_HOME}/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"]
|
||||
COPY InvenTree ./InvenTree
|
||||
|
||||
# Launch the production server
|
||||
# TODO: Work out why environment variables cannot be interpolated in this command
|
||||
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
|
||||
CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
|
||||
|
||||
|
||||
FROM inventree_base as dev
|
||||
|
||||
# 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}
|
||||
|
||||
# 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
|
||||
CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"]
|
||||
|
@ -306,47 +306,6 @@ class APISearchView(APIView):
|
||||
return Response(results)
|
||||
|
||||
|
||||
class StatusView(APIView):
|
||||
"""Generic API endpoint for discovering information on 'status codes' for a particular model.
|
||||
|
||||
This class should be implemented as a subclass for each type of status.
|
||||
For example, the API endpoint /stock/status/ will have information about
|
||||
all available 'StockStatus' codes
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
# Override status_class for implementing subclass
|
||||
MODEL_REF = 'statusmodel'
|
||||
|
||||
def get_status_model(self, *args, **kwargs):
|
||||
"""Return the StatusCode moedl based on extra parameters passed to the view"""
|
||||
|
||||
status_model = self.kwargs.get(self.MODEL_REF, None)
|
||||
|
||||
if status_model is None:
|
||||
raise ValidationError(f"StatusView view called without '{self.MODEL_REF}' parameter")
|
||||
|
||||
return status_model
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform a GET request to learn information about status codes"""
|
||||
|
||||
status_class = self.get_status_model()
|
||||
|
||||
if not status_class:
|
||||
raise NotImplementedError("status_class not defined for this endpoint")
|
||||
|
||||
data = {
|
||||
'class': status_class.__name__,
|
||||
'values': status_class.dict(),
|
||||
}
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
class MetadataView(RetrieveUpdateAPI):
|
||||
"""Generic API endpoint for reading and editing metadata for a model"""
|
||||
|
||||
|
@ -2,14 +2,44 @@
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
- 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
|
||||
- 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
|
||||
- Adds "delivery_date" to shipments
|
||||
>>>>>>> inventree/master
|
||||
|
||||
v113 -> 2023-05-13 : https://github.com/inventree/InvenTree/pull/4800
|
||||
- Adds API endpoints for scrapping a build output
|
||||
|
@ -53,7 +53,7 @@ class InvenTreeConfig(AppConfig):
|
||||
self.collect_notification_methods()
|
||||
|
||||
# Ensure the unit registry is loaded
|
||||
InvenTree.conversion.reload_unit_registry()
|
||||
InvenTree.conversion.get_unit_registry()
|
||||
|
||||
if canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
self.add_user_on_startup()
|
||||
@ -131,7 +131,10 @@ class InvenTreeConfig(AppConfig):
|
||||
update = False
|
||||
|
||||
try:
|
||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
|
||||
|
||||
if backend.exists():
|
||||
backend = backend.first()
|
||||
|
||||
last_update = backend.last_update
|
||||
|
||||
@ -197,8 +200,8 @@ class InvenTreeConfig(AppConfig):
|
||||
else:
|
||||
new_user = user.objects.create_superuser(add_user, add_email, add_password)
|
||||
logger.info(f'User {str(new_user)} was created!')
|
||||
except IntegrityError as _e:
|
||||
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
|
||||
except IntegrityError:
|
||||
logger.warning(f'The user "{add_user}" could not be created')
|
||||
|
||||
# do not try again
|
||||
settings.USER_ADDED = True
|
||||
|
@ -4,10 +4,8 @@
|
||||
|
||||
import InvenTree.email
|
||||
import InvenTree.status
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
ReturnOrderLineStatus, ReturnOrderStatus,
|
||||
SalesOrderStatus, StockHistoryCode,
|
||||
StockStatus)
|
||||
from generic.states import StatusCode
|
||||
from InvenTree.helpers import inheritors
|
||||
from users.models import RuleSet, check_user_role
|
||||
|
||||
|
||||
@ -57,17 +55,7 @@ def status_codes(request):
|
||||
return {}
|
||||
|
||||
request._inventree_status_codes = True
|
||||
|
||||
return {
|
||||
# Expose the StatusCode classes to the templates
|
||||
'ReturnOrderStatus': ReturnOrderStatus,
|
||||
'ReturnOrderLineStatus': ReturnOrderLineStatus,
|
||||
'SalesOrderStatus': SalesOrderStatus,
|
||||
'PurchaseOrderStatus': PurchaseOrderStatus,
|
||||
'BuildStatus': BuildStatus,
|
||||
'StockStatus': StockStatus,
|
||||
'StockHistoryCode': StockHistoryCode,
|
||||
}
|
||||
return {cls.__name__: cls.template_context() for cls in inheritors(StatusCode)}
|
||||
|
||||
|
||||
def user_roles(request):
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""Helper functions for converting between units."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -8,6 +10,9 @@ import pint
|
||||
_unit_registry = None
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def get_unit_registry():
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import time
|
||||
t_start = time.time()
|
||||
|
||||
global _unit_registry
|
||||
|
||||
_unit_registry = pint.UnitRegistry()
|
||||
@ -39,6 +47,9 @@ def reload_unit_registry():
|
||||
|
||||
# TODO: Allow for custom units to be defined in the database
|
||||
|
||||
dt = time.time() - t_start
|
||||
logger.debug(f'Loaded unit registry in {dt:.3f}s')
|
||||
|
||||
|
||||
def convert_physical_value(value: str, unit: str = None):
|
||||
"""Validate that the provided value is a valid physical quantity.
|
||||
@ -80,7 +91,7 @@ def convert_physical_value(value: str, unit: str = None):
|
||||
# At this point we *should* have a valid pint value
|
||||
# To double check, look at the maginitude
|
||||
float(val.magnitude)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
error = _('Provided value is not a valid number')
|
||||
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
|
||||
error = _('Provided value has an invalid unit')
|
||||
|
@ -21,6 +21,8 @@ from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
|
||||
PrependedText)
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Field, Layout
|
||||
from dj_rest_auth.registration.serializers import RegisterSerializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.exceptions import log_error
|
||||
@ -206,6 +208,11 @@ class CustomSignupForm(SignupForm):
|
||||
return cleaned_data
|
||||
|
||||
|
||||
def registration_enabled():
|
||||
"""Determine whether user registration is enabled."""
|
||||
return settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'))
|
||||
|
||||
|
||||
class RegistratonMixin:
|
||||
"""Mixin to check if registration should be enabled."""
|
||||
|
||||
@ -214,7 +221,7 @@ class RegistratonMixin:
|
||||
|
||||
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 False
|
||||
|
||||
@ -319,3 +326,20 @@ class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocial
|
||||
|
||||
# Otherwise defer to the original allauth adapter.
|
||||
return super().login(request, user)
|
||||
|
||||
|
||||
# override dj-rest-auth
|
||||
class CustomRegisterSerializer(RegisterSerializer):
|
||||
"""Override of serializer to use dynamic settings."""
|
||||
email = serializers.EmailField()
|
||||
|
||||
def __init__(self, instance=None, data=..., **kwargs):
|
||||
"""Check settings to influence which fields are needed."""
|
||||
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
|
||||
super().__init__(instance, data, **kwargs)
|
||||
|
||||
def save(self, request):
|
||||
"""Override to check if registration is open."""
|
||||
if registration_enabled():
|
||||
return super().save(request)
|
||||
raise forms.ValidationError(_('Registration is disabled.'))
|
||||
|
@ -262,7 +262,22 @@ def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNo
|
||||
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
|
||||
exclude (User, optional): User instance that should be excluded. Defaults to None.
|
||||
"""
|
||||
if instance.responsible is not None:
|
||||
notify_users([instance.responsible], instance, sender, content=content, exclude=exclude)
|
||||
|
||||
|
||||
def notify_users(users, instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None):
|
||||
"""Notify all passed users or groups.
|
||||
|
||||
Parses the supplied content with the provided instance and sender and sends a notification to all users,
|
||||
excluding the optional excluded list.
|
||||
|
||||
Args:
|
||||
users: List of users or groups to notify
|
||||
instance: The newly created instance
|
||||
sender: Sender model reference
|
||||
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
|
||||
exclude (User, optional): User instance that should be excluded. Defaults to None.
|
||||
"""
|
||||
# Setup context for notification parsing
|
||||
content_context = {
|
||||
'instance': str(instance),
|
||||
@ -278,16 +293,18 @@ def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNo
|
||||
'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),
|
||||
}
|
||||
}
|
||||
|
||||
if content.template:
|
||||
context['template']['html'] = content.template.format(**content_context)
|
||||
|
||||
# Create notification
|
||||
trigger_notification(
|
||||
instance,
|
||||
content.slug.format(**content_context),
|
||||
targets=[instance.responsible],
|
||||
targets=users,
|
||||
target_exclude=[exclude],
|
||||
context=context,
|
||||
)
|
||||
|
75
InvenTree/InvenTree/magic_login.py
Normal file
75
InvenTree/InvenTree/magic_login.py
Normal file
@ -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:
|
||||
|
||||
time.sleep(5)
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
connection.ensure_connection()
|
||||
|
@ -14,7 +14,6 @@ from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
|
||||
from error_report.middleware import ExceptionProcessor
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.urls import frontendpatterns
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
@ -65,6 +64,9 @@ class AuthRequiredMiddleware(object):
|
||||
elif request.path_info.startswith('/accounts/'):
|
||||
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():
|
||||
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."""
|
||||
def require_2fa(self, request):
|
||||
"""Use setting to check if MFA should be enforced for frontend page."""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
try:
|
||||
if url_matcher.resolve(request.path[1:]):
|
||||
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')
|
||||
@ -165,4 +170,31 @@ class InvenTreeExceptionProcessor(ExceptionProcessor):
|
||||
if kind in settings.IGNORED_ERRORS:
|
||||
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
|
||||
|
||||
|
||||
def isRunningMigrations():
|
||||
"""Return True if the database is currently running migrations."""
|
||||
return 'migrate' in sys.argv or 'makemigrations' in sys.argv
|
||||
|
||||
|
||||
def isInMainThread():
|
||||
"""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 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 InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
|
||||
from InvenTree.helpers_model import download_image_from_url
|
||||
@ -724,7 +724,7 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
|
||||
if not url:
|
||||
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"))
|
||||
|
||||
try:
|
||||
|
@ -22,6 +22,7 @@ from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import moneyed
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
|
||||
from InvenTree.sentry import default_sentry_dsn, init_sentry
|
||||
@ -65,6 +66,12 @@ BASE_DIR = config.get_base_dir()
|
||||
# Load configuration data
|
||||
CONFIG = config.load_config_data(set_cache=True)
|
||||
|
||||
# Load VERSION data if it exists
|
||||
version_file = BASE_DIR.parent.joinpath('VERSION')
|
||||
if version_file.exists():
|
||||
print('load version from file')
|
||||
load_dotenv(version_file)
|
||||
|
||||
# Default action is to run the system in Debug mode
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', True)
|
||||
@ -196,6 +203,7 @@ INSTALLED_APPS = [
|
||||
'stock.apps.StockConfig',
|
||||
'users.apps.UsersConfig',
|
||||
'plugin.apps.PluginAppConfig',
|
||||
'generic',
|
||||
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
|
||||
|
||||
# Core django modules
|
||||
@ -226,6 +234,7 @@ INSTALLED_APPS = [
|
||||
'formtools', # Form wizard tools
|
||||
'dbbackup', # Backups - django-dbbackup
|
||||
'taggit', # Tagging
|
||||
'flags', # Flagging - django-flags
|
||||
|
||||
'allauth', # Base app for SSO
|
||||
'allauth.account', # Extend user with accounts
|
||||
@ -236,6 +245,8 @@ INSTALLED_APPS = [
|
||||
'django_otp.plugins.otp_static', # Backup codes
|
||||
|
||||
'allauth_2fa', # MFA flow for allauth
|
||||
'dj_rest_auth', # Authentication APIs - dj-rest-auth
|
||||
'dj_rest_auth.registration', # Registration APIs - dj-rest-auth'
|
||||
'drf_spectacular', # API documentation
|
||||
|
||||
'django_ical', # For exporting calendars
|
||||
@ -265,6 +276,7 @@ AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
|
||||
'django.contrib.auth.backends.RemoteUserBackend', # proxy login
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'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)
|
||||
@ -371,6 +383,23 @@ if DEBUG:
|
||||
# Enable browsable API if in DEBUG mode
|
||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer')
|
||||
|
||||
# dj-rest-auth
|
||||
# JWT switch
|
||||
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
|
||||
REST_USE_JWT = USE_JWT
|
||||
OLD_PASSWORD_FIELD_ENABLED = True
|
||||
REST_AUTH_REGISTER_SERIALIZERS = {'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'}
|
||||
|
||||
# JWT settings - rest_framework_simplejwt
|
||||
if USE_JWT:
|
||||
JWT_AUTH_COOKIE = 'inventree-auth'
|
||||
JWT_AUTH_REFRESH_COOKIE = 'inventree-token'
|
||||
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] + (
|
||||
'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
|
||||
)
|
||||
INSTALLED_APPS.append('rest_framework_simplejwt')
|
||||
|
||||
# WSGI default setting
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'InvenTree API',
|
||||
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
|
||||
@ -573,6 +602,10 @@ DATABASES = {
|
||||
REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False)
|
||||
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER')
|
||||
|
||||
# Magic login django-sesame
|
||||
SESAME_MAX_AGE = 300
|
||||
LOGIN_REDIRECT_URL = "/platform/logged-in/"
|
||||
|
||||
# sentry.io integration for error reporting
|
||||
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_LOGOUT_ON_PASSWORD_CHANGE = True
|
||||
ACCOUNT_PREVENT_ENUMERATION = True
|
||||
# 2FA
|
||||
REMOVE_SUCCESS_URL = 'settings'
|
||||
|
||||
# override forms / adapters
|
||||
ACCOUNT_FORMS = {
|
||||
@ -936,3 +971,23 @@ if DEBUG:
|
||||
|
||||
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
|
||||
# Flags
|
||||
FLAGS = {
|
||||
'EXPERIMENTAL': [
|
||||
{'condition': 'boolean', 'value': DEBUG},
|
||||
{'condition': 'parameter', 'value': 'experimental='},
|
||||
], # Should experimental features be turned on?
|
||||
'NEXT_GEN': [
|
||||
{'condition': 'parameter', 'value': 'ngen='},
|
||||
], # Should next-gen features be turned on?
|
||||
}
|
||||
|
||||
# Get custom flags from environment/yaml
|
||||
CUSTOM_FLAGS = get_setting('INVENTREE_FLAGS', 'flags', None, typecast=dict)
|
||||
if CUSTOM_FLAGS:
|
||||
if not isinstance(CUSTOM_FLAGS, dict):
|
||||
logger.error(f"Invalid custom flags, must be valid dict: {CUSTOM_FLAGS}")
|
||||
else:
|
||||
logger.info(f"Custom flags: {CUSTOM_FLAGS}")
|
||||
FLAGS.update(CUSTOM_FLAGS)
|
||||
|
127
InvenTree/InvenTree/social_auth_urls.py
Normal file
127
InvenTree/InvenTree/social_auth_urls.py
Normal file
@ -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 {
|
||||
margin-left: 45px;
|
||||
margin-right: 45px;
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
.detail-icon .glyphicon {
|
||||
@ -270,10 +269,6 @@ main {
|
||||
}
|
||||
|
||||
/* Styles for table buttons and filtering */
|
||||
.button-toolbar .btn {
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.filter-list {
|
||||
display: inline-block;
|
||||
|
@ -38,7 +38,14 @@ def is_worker_running(**kwargs):
|
||||
)
|
||||
|
||||
# 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):
|
||||
|
@ -2,377 +2,161 @@
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
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")
|
||||
from generic.states import StatusCode
|
||||
|
||||
|
||||
class PurchaseOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a PurchaseOrder."""
|
||||
|
||||
# Order status codes
|
||||
PENDING = 10 # Order is pending (not yet placed)
|
||||
PLACED = 20 # Order has been placed with supplier
|
||||
COMPLETE = 30 # Order has been completed
|
||||
CANCELLED = 40 # Order was cancelled
|
||||
LOST = 50 # Order was lost
|
||||
RETURNED = 60 # Order was returned
|
||||
PENDING = 10, _("Pending"), 'secondary' # Order is pending (not yet placed)
|
||||
PLACED = 20, _("Placed"), 'primary' # Order has been placed with supplier
|
||||
COMPLETE = 30, _("Complete"), 'success' # Order has been completed
|
||||
CANCELLED = 40, _("Cancelled"), 'danger' # Order was cancelled
|
||||
LOST = 50, _("Lost"), 'warning' # Order was lost
|
||||
RETURNED = 60, _("Returned"), 'warning' # Order was returned
|
||||
|
||||
options = {
|
||||
PENDING: _("Pending"),
|
||||
PLACED: _("Placed"),
|
||||
COMPLETE: _("Complete"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
LOST: _("Lost"),
|
||||
RETURNED: _("Returned"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
PLACED: 'primary',
|
||||
COMPLETE: 'success',
|
||||
CANCELLED: 'danger',
|
||||
LOST: 'warning',
|
||||
RETURNED: 'warning',
|
||||
}
|
||||
class PurchaseOrderStatusGroups:
|
||||
"""Groups for PurchaseOrderStatus codes."""
|
||||
|
||||
# Open orders
|
||||
OPEN = [
|
||||
PENDING,
|
||||
PLACED,
|
||||
PurchaseOrderStatus.PENDING.value,
|
||||
PurchaseOrderStatus.PLACED.value,
|
||||
]
|
||||
|
||||
# Failed orders
|
||||
FAILED = [
|
||||
CANCELLED,
|
||||
LOST,
|
||||
RETURNED
|
||||
PurchaseOrderStatus.CANCELLED.value,
|
||||
PurchaseOrderStatus.LOST.value,
|
||||
PurchaseOrderStatus.RETURNED.value
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a SalesOrder."""
|
||||
|
||||
PENDING = 10 # Order is pending
|
||||
IN_PROGRESS = 15 # Order has been issued, and is in progress
|
||||
SHIPPED = 20 # Order has been shipped to customer
|
||||
CANCELLED = 40 # Order has been cancelled
|
||||
LOST = 50 # Order was lost
|
||||
RETURNED = 60 # Order was returned
|
||||
PENDING = 10, _("Pending"), 'secondary' # Order is pending
|
||||
IN_PROGRESS = 15, _("In Progress"), 'primary' # Order has been issued, and is in progress
|
||||
SHIPPED = 20, _("Shipped"), 'success' # Order has been shipped to customer
|
||||
CANCELLED = 40, _("Cancelled"), 'danger' # Order has been cancelled
|
||||
LOST = 50, _("Lost"), 'warning' # Order was lost
|
||||
RETURNED = 60, _("Returned"), 'warning' # Order was returned
|
||||
|
||||
options = {
|
||||
PENDING: _("Pending"),
|
||||
IN_PROGRESS: _("In Progress"),
|
||||
SHIPPED: _("Shipped"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
LOST: _("Lost"),
|
||||
RETURNED: _("Returned"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'primary',
|
||||
SHIPPED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
LOST: 'warning',
|
||||
RETURNED: 'warning',
|
||||
}
|
||||
class SalesOrderStatusGroups:
|
||||
"""Groups for SalesOrderStatus codes."""
|
||||
|
||||
# Open orders
|
||||
OPEN = [
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
SalesOrderStatus.PENDING.value,
|
||||
SalesOrderStatus.IN_PROGRESS.value,
|
||||
]
|
||||
|
||||
# Completed orders
|
||||
COMPLETE = [
|
||||
SHIPPED,
|
||||
SalesOrderStatus.SHIPPED.value,
|
||||
]
|
||||
|
||||
|
||||
class StockStatus(StatusCode):
|
||||
"""Status codes for Stock."""
|
||||
|
||||
OK = 10 # Item is OK
|
||||
ATTENTION = 50 # Item requires attention
|
||||
DAMAGED = 55 # Item is damaged
|
||||
DESTROYED = 60 # Item is destroyed
|
||||
REJECTED = 65 # Item is rejected
|
||||
LOST = 70 # Item has been lost
|
||||
QUARANTINED = 75 # Item has been quarantined and is unavailable
|
||||
RETURNED = 85 # Item has been returned from a customer
|
||||
OK = 10, _("OK"), 'success' # Item is OK
|
||||
ATTENTION = 50, _("Attention needed"), 'warning' # Item requires attention
|
||||
DAMAGED = 55, _("Damaged"), 'warning' # Item is damaged
|
||||
DESTROYED = 60, _("Destroyed"), 'danger' # Item is destroyed
|
||||
REJECTED = 65, _("Rejected"), 'danger' # Item is rejected
|
||||
LOST = 70, _("Lost"), 'dark' # Item has been lost
|
||||
QUARANTINED = 75, _("Quarantined"), 'info' # Item has been quarantined and is unavailable
|
||||
RETURNED = 85, _("Returned"), 'warning' # Item has been returned from a customer
|
||||
|
||||
options = {
|
||||
OK: _("OK"),
|
||||
ATTENTION: _("Attention needed"),
|
||||
DAMAGED: _("Damaged"),
|
||||
DESTROYED: _("Destroyed"),
|
||||
LOST: _("Lost"),
|
||||
REJECTED: _("Rejected"),
|
||||
QUARANTINED: _("Quarantined"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
OK: 'success',
|
||||
ATTENTION: 'warning',
|
||||
DAMAGED: 'warning',
|
||||
DESTROYED: 'danger',
|
||||
LOST: 'dark',
|
||||
REJECTED: 'danger',
|
||||
QUARANTINED: 'info'
|
||||
}
|
||||
class StockStatusGroups:
|
||||
"""Groups for StockStatus codes."""
|
||||
|
||||
# The following codes correspond to parts that are 'available' or 'in stock'
|
||||
AVAILABLE_CODES = [
|
||||
OK,
|
||||
ATTENTION,
|
||||
DAMAGED,
|
||||
RETURNED,
|
||||
StockStatus.OK.value,
|
||||
StockStatus.ATTENTION.value,
|
||||
StockStatus.DAMAGED.value,
|
||||
StockStatus.RETURNED.value,
|
||||
]
|
||||
|
||||
|
||||
class StockHistoryCode(StatusCode):
|
||||
"""Status codes for StockHistory."""
|
||||
|
||||
LEGACY = 0
|
||||
LEGACY = 0, _('Legacy stock tracking entry')
|
||||
|
||||
CREATED = 1
|
||||
CREATED = 1, _('Stock item created')
|
||||
|
||||
# Manual editing operations
|
||||
EDITED = 5
|
||||
ASSIGNED_SERIAL = 6
|
||||
EDITED = 5, _('Edited stock item')
|
||||
ASSIGNED_SERIAL = 6, _('Assigned serial number')
|
||||
|
||||
# Manual stock operations
|
||||
STOCK_COUNT = 10
|
||||
STOCK_ADD = 11
|
||||
STOCK_REMOVE = 12
|
||||
STOCK_COUNT = 10, _('Stock counted')
|
||||
STOCK_ADD = 11, _('Stock manually added')
|
||||
STOCK_REMOVE = 12, _('Stock manually removed')
|
||||
|
||||
# Location operations
|
||||
STOCK_MOVE = 20
|
||||
STOCK_UPDATE = 25
|
||||
STOCK_MOVE = 20, _('Location changed')
|
||||
STOCK_UPDATE = 25, _('Stock updated')
|
||||
|
||||
# Installation operations
|
||||
INSTALLED_INTO_ASSEMBLY = 30
|
||||
REMOVED_FROM_ASSEMBLY = 31
|
||||
INSTALLED_INTO_ASSEMBLY = 30, _('Installed into assembly')
|
||||
REMOVED_FROM_ASSEMBLY = 31, _('Removed from assembly')
|
||||
|
||||
INSTALLED_CHILD_ITEM = 35
|
||||
REMOVED_CHILD_ITEM = 36
|
||||
INSTALLED_CHILD_ITEM = 35, _('Installed component item')
|
||||
REMOVED_CHILD_ITEM = 36, _('Removed component item')
|
||||
|
||||
# Stock splitting operations
|
||||
SPLIT_FROM_PARENT = 40
|
||||
SPLIT_CHILD_ITEM = 42
|
||||
SPLIT_FROM_PARENT = 40, _('Split from parent item')
|
||||
SPLIT_CHILD_ITEM = 42, _('Split child item')
|
||||
|
||||
# Stock merging operations
|
||||
MERGED_STOCK_ITEMS = 45
|
||||
MERGED_STOCK_ITEMS = 45, _('Merged stock items')
|
||||
|
||||
# Convert stock item to variant
|
||||
CONVERTED_TO_VARIANT = 48
|
||||
CONVERTED_TO_VARIANT = 48, _('Converted to variant')
|
||||
|
||||
# Build order codes
|
||||
BUILD_OUTPUT_CREATED = 50
|
||||
BUILD_OUTPUT_COMPLETED = 55
|
||||
BUILD_OUTPUT_REJECTED = 56
|
||||
BUILD_CONSUMED = 57
|
||||
BUILD_OUTPUT_CREATED = 50, _('Build order output created')
|
||||
BUILD_OUTPUT_COMPLETED = 55, _('Build order output completed')
|
||||
BUILD_OUTPUT_REJECTED = 56, _('Build order output rejected')
|
||||
BUILD_CONSUMED = 57, _('Consumed by build order')
|
||||
|
||||
# Sales order codes
|
||||
SHIPPED_AGAINST_SALES_ORDER = 60
|
||||
SHIPPED_AGAINST_SALES_ORDER = 60, _("Shipped against Sales Order")
|
||||
|
||||
# Purchase order codes
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER = 70
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER = 70, _('Received against Purchase Order')
|
||||
|
||||
# Return order codes
|
||||
RETURNED_AGAINST_RETURN_ORDER = 80
|
||||
RETURNED_AGAINST_RETURN_ORDER = 80, _('Returned against Return Order')
|
||||
|
||||
# Customer actions
|
||||
SENT_TO_CUSTOMER = 100
|
||||
RETURNED_FROM_CUSTOMER = 105
|
||||
|
||||
options = {
|
||||
LEGACY: _('Legacy stock tracking entry'),
|
||||
|
||||
CREATED: _('Stock item created'),
|
||||
|
||||
EDITED: _('Edited stock item'),
|
||||
ASSIGNED_SERIAL: _('Assigned serial number'),
|
||||
|
||||
STOCK_COUNT: _('Stock counted'),
|
||||
STOCK_ADD: _('Stock manually added'),
|
||||
STOCK_REMOVE: _('Stock manually removed'),
|
||||
|
||||
STOCK_MOVE: _('Location changed'),
|
||||
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'),
|
||||
}
|
||||
SENT_TO_CUSTOMER = 100, _('Sent to customer')
|
||||
RETURNED_FROM_CUSTOMER = 105, _('Returned from customer')
|
||||
|
||||
|
||||
class BuildStatus(StatusCode):
|
||||
"""Build status codes."""
|
||||
|
||||
PENDING = 10 # Build is pending / active
|
||||
PRODUCTION = 20 # BuildOrder is in production
|
||||
CANCELLED = 30 # Build was cancelled
|
||||
COMPLETE = 40 # Build is complete
|
||||
PENDING = 10, _("Pending"), 'secondary' # Build is pending / active
|
||||
PRODUCTION = 20, _("Production"), 'primary' # BuildOrder is in production
|
||||
CANCELLED = 30, _("Cancelled"), 'danger' # Build was cancelled
|
||||
COMPLETE = 40, _("Complete"), 'success' # Build is complete
|
||||
|
||||
options = {
|
||||
PENDING: _("Pending"),
|
||||
PRODUCTION: _("Production"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
COMPLETE: _("Complete"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
PRODUCTION: 'primary',
|
||||
COMPLETE: 'success',
|
||||
CANCELLED: 'danger',
|
||||
}
|
||||
class BuildStatusGroups:
|
||||
"""Groups for BuildStatus codes."""
|
||||
|
||||
ACTIVE_CODES = [
|
||||
PENDING,
|
||||
PRODUCTION,
|
||||
BuildStatus.PENDING.value,
|
||||
BuildStatus.PRODUCTION.value,
|
||||
]
|
||||
|
||||
|
||||
@ -380,68 +164,40 @@ class ReturnOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a ReturnOrder"""
|
||||
|
||||
# Order is pending, waiting for receipt of items
|
||||
PENDING = 10
|
||||
PENDING = 10, _("Pending"), 'secondary'
|
||||
|
||||
# Items have been received, and are being inspected
|
||||
IN_PROGRESS = 20
|
||||
IN_PROGRESS = 20, _("In Progress"), 'primary'
|
||||
|
||||
COMPLETE = 30
|
||||
CANCELLED = 40
|
||||
COMPLETE = 30, _("Complete"), 'success'
|
||||
CANCELLED = 40, _("Cancelled"), 'danger'
|
||||
|
||||
|
||||
class ReturnOrderStatusGroups:
|
||||
"""Groups for ReturnOrderStatus codes."""
|
||||
|
||||
OPEN = [
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
ReturnOrderStatus.PENDING.value,
|
||||
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):
|
||||
"""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
|
||||
RETURN = 20
|
||||
RETURN = 20, _("Return"), 'success'
|
||||
|
||||
# Item is to be repaired, and returned to customer
|
||||
REPAIR = 30
|
||||
REPAIR = 30, _("Repair"), 'primary'
|
||||
|
||||
# Item is to be replaced (new item shipped)
|
||||
REPLACE = 40
|
||||
REPLACE = 40, _("Replace"), 'warning'
|
||||
|
||||
# Item is to be refunded (cannot be repaired)
|
||||
REFUND = 50
|
||||
REFUND = 50, _("Refund"), 'info'
|
||||
|
||||
# Item is rejected
|
||||
REJECT = 60
|
||||
|
||||
options = {
|
||||
PENDING: _('Pending'),
|
||||
RETURN: _('Return'),
|
||||
REPAIR: _('Repair'),
|
||||
REFUND: _('Refund'),
|
||||
REPLACE: _('Replace'),
|
||||
REJECT: _('Reject')
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
RETURN: 'success',
|
||||
REPAIR: 'primary',
|
||||
REFUND: 'info',
|
||||
REPLACE: 'warning',
|
||||
REJECT: 'danger',
|
||||
}
|
||||
REJECT = 60, _("Reject"), 'danger'
|
||||
|
@ -91,12 +91,14 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
|
||||
"""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.ready import isInTestMode
|
||||
|
||||
if n_days <= 0:
|
||||
logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run")
|
||||
return False
|
||||
|
||||
# Sleep a random number of seconds to prevent worker conflict
|
||||
if not isInTestMode():
|
||||
time.sleep(random.randint(1, 5))
|
||||
|
||||
attempt_key = f'_{task_name}_ATTEMPT'
|
||||
@ -186,6 +188,8 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
|
||||
task.run()
|
||||
except ImportError:
|
||||
raise_warning(f"WARNING: '{taskname}' not started - Function not found")
|
||||
except Exception as exc:
|
||||
raise_warning(f"WARNING: '{taskname}' not started due to {type(exc)}")
|
||||
else:
|
||||
|
||||
if callable(taskname):
|
||||
@ -495,7 +499,7 @@ def check_for_updates():
|
||||
def update_exchange_rates():
|
||||
"""Update currency exchange rates."""
|
||||
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 InvenTree.exchange import InvenTreeExchange
|
||||
@ -507,22 +511,9 @@ def update_exchange_rates():
|
||||
# Other error?
|
||||
return
|
||||
|
||||
# Test to see if the database is ready yet
|
||||
try:
|
||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||
except ExchangeBackend.DoesNotExist:
|
||||
pass
|
||||
except Exception: # pragma: no cover
|
||||
# Some other error
|
||||
logger.warning("update_exchange_rates: Database not ready")
|
||||
return
|
||||
|
||||
backend = InvenTreeExchange()
|
||||
logger.info(f"Updating exchange rates from {backend.url}")
|
||||
|
||||
base = currency_code_default()
|
||||
|
||||
logger.info(f"Using base currency '{base}'")
|
||||
logger.info(f"Updating exchange rates using base currency '{base}'")
|
||||
|
||||
try:
|
||||
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
|
||||
fixtures = [
|
||||
'settings',
|
||||
'build',
|
||||
'company',
|
||||
'manufacturer_part',
|
||||
'price_breaks',
|
||||
'supplier_part',
|
||||
'order',
|
||||
'sales_order',
|
||||
'bom',
|
||||
'category',
|
||||
'params',
|
||||
'part_pricebreaks',
|
||||
'part',
|
||||
'bom',
|
||||
'build',
|
||||
'test_templates',
|
||||
'location',
|
||||
'stock_tests',
|
||||
|
@ -11,12 +11,15 @@ import django.core.exceptions as django_exceptions
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import mail
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import Rate, convert_money
|
||||
from djmoney.money import Money
|
||||
from sesame.utils import get_user
|
||||
|
||||
import InvenTree.conversion
|
||||
import InvenTree.format
|
||||
@ -56,6 +59,23 @@ class ConversionTest(TestCase):
|
||||
q = InvenTree.conversion.convert_physical_value(val).to_base_units()
|
||||
self.assertEqual(q.magnitude, expected)
|
||||
|
||||
def test_invalid_values(self):
|
||||
"""Test conversion of invalid inputs"""
|
||||
|
||||
inputs = [
|
||||
'-',
|
||||
';;',
|
||||
'-x',
|
||||
'?',
|
||||
'--',
|
||||
'+',
|
||||
'++',
|
||||
]
|
||||
|
||||
for val in inputs:
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTree.conversion.convert_physical_value(val)
|
||||
|
||||
|
||||
class ValidatorTest(TestCase):
|
||||
"""Simple tests for custom field validators."""
|
||||
@ -1044,3 +1064,38 @@ class SanitizerTest(TestCase):
|
||||
|
||||
# Test that invalid string is cleanded
|
||||
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 io
|
||||
import json
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.db import connections
|
||||
from django.http.response import StreamingHttpResponse
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from rest_framework.test import APITestCase
|
||||
@ -233,9 +237,38 @@ class ExchangeRateMixin:
|
||||
Rate.objects.bulk_create(items)
|
||||
|
||||
|
||||
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
|
||||
"""Testcase with user setup buildin."""
|
||||
pass
|
||||
|
||||
|
||||
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
"""Base class for running InvenTree API tests."""
|
||||
|
||||
@contextmanager
|
||||
def assertNumQueriesLessThan(self, value, using='default', verbose=False, debug=False):
|
||||
"""Context manager to check that the number of queries is less than a certain value.
|
||||
|
||||
Example:
|
||||
with self.assertNumQueriesLessThan(10):
|
||||
# Do some stuff
|
||||
Ref: https://stackoverflow.com/questions/1254170/django-is-there-a-way-to-count-sql-queries-from-an-unit-test/59089020#59089020
|
||||
"""
|
||||
with CaptureQueriesContext(connections[using]) as context:
|
||||
yield # your test will be run here
|
||||
|
||||
if verbose:
|
||||
msg = "\r\n%s" % json.dumps(context.captured_queries, indent=4)
|
||||
else:
|
||||
msg = None
|
||||
|
||||
n = len(context.captured_queries)
|
||||
|
||||
if debug:
|
||||
print(f"Expected less than {value} queries, got {n} queries")
|
||||
|
||||
self.assertLess(n, value, msg=msg)
|
||||
|
||||
def checkResponse(self, url, method, expected_code, response):
|
||||
"""Debug output for an unexpected response"""
|
||||
|
||||
@ -408,8 +441,3 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
data.append(entry)
|
||||
|
||||
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.contrib import admin
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
from dj_rest_auth.registration.views import (SocialAccountDisconnectView,
|
||||
SocialAccountListView)
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
|
||||
from sesame.views import LoginView
|
||||
|
||||
from build.api import build_api_urls
|
||||
from build.urls import build_urls
|
||||
@ -31,13 +35,15 @@ from stock.urls import stock_urls
|
||||
from users.api import user_urls
|
||||
|
||||
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,
|
||||
CustomEmailView, CustomLoginView,
|
||||
CustomPasswordResetFromKeyView,
|
||||
CustomSessionDeleteOtherView, CustomSessionDeleteView,
|
||||
CustomTwoFactorRemove, DatabaseStatsView, DynamicJsView,
|
||||
EditUserView, IndexView, NotificationsView, SearchView,
|
||||
SetPasswordView, SettingsView, auth_request)
|
||||
DatabaseStatsView, DynamicJsView, EditUserView, IndexView,
|
||||
NotificationsView, SearchView, SetPasswordView,
|
||||
SettingsView, auth_request)
|
||||
|
||||
admin.site.site_header = "InvenTree Admin"
|
||||
|
||||
@ -71,6 +77,18 @@ apipatterns = [
|
||||
# InvenTree information endpoint
|
||||
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
|
||||
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
|
||||
]
|
||||
@ -95,6 +113,7 @@ notifications_urls = [
|
||||
dynamic_javascript_urls = [
|
||||
re_path(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'),
|
||||
re_path(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'),
|
||||
re_path(r'^permissions.js', DynamicJsView.as_view(template_name='js/dynamic/permissions.js'), name='permissions.js'),
|
||||
re_path(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'),
|
||||
]
|
||||
|
||||
@ -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'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
|
||||
re_path(r'^helpers.js', DynamicJsView.as_view(template_name='js/translated/helpers.js'), name='helpers.js'),
|
||||
re_path(r'^index.js', DynamicJsView.as_view(template_name='js/translated/index.js'), name='index.js'),
|
||||
re_path(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'),
|
||||
re_path(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'),
|
||||
re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
|
||||
@ -178,10 +198,6 @@ frontendpatterns = [
|
||||
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"),
|
||||
|
||||
# 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
|
||||
re_path("accounts/login/", CustomLoginView.as_view(), name="account_login"),
|
||||
|
||||
|
@ -5,24 +5,26 @@ Provides information on the current InvenTree version
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta as td
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
from dulwich.repo import NotGitRepository, Repo
|
||||
|
||||
from .api_version import INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = "0.12.0 dev"
|
||||
INVENTREE_SW_VERSION = "0.13.0 dev"
|
||||
|
||||
# Discover git
|
||||
try:
|
||||
main_repo = Repo(pathlib.Path(__file__).parent.parent.parent)
|
||||
main_commit = main_repo[main_repo.head()]
|
||||
except NotGitRepository:
|
||||
except (NotGitRepository, FileNotFoundError):
|
||||
main_commit = None
|
||||
|
||||
|
||||
@ -130,3 +132,51 @@ def inventreeCommitDate():
|
||||
|
||||
commit_dt = dt.fromtimestamp(main_commit.commit_time) + td(seconds=main_commit.commit_timezone)
|
||||
return str(commit_dt.date())
|
||||
|
||||
|
||||
def inventreeInstaller():
|
||||
"""Returns the installer for the running codebase - if set."""
|
||||
# First look in the environment variables, e.g. if running in docker
|
||||
|
||||
installer = os.environ.get('INVENTREE_PKG_INSTALLER', '')
|
||||
|
||||
if installer:
|
||||
return installer
|
||||
elif settings.DOCKER:
|
||||
return 'DOC'
|
||||
elif main_commit is not None:
|
||||
return 'GIT'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def inventreeBranch():
|
||||
"""Returns the branch for the running codebase - if set."""
|
||||
# First look in the environment variables, e.g. if running in docker
|
||||
|
||||
branch = os.environ.get('INVENTREE_PKG_BRANCH', '')
|
||||
|
||||
if branch:
|
||||
return branch
|
||||
|
||||
if main_commit is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
branch = main_repo.refs.follow(b'HEAD')[0][1].decode()
|
||||
return branch.removeprefix('refs/heads/')
|
||||
except IndexError:
|
||||
return None # pragma: no cover
|
||||
|
||||
|
||||
def inventreeTarget():
|
||||
"""Returns the target platform for the running codebase - if set."""
|
||||
# First look in the environment variables, e.g. if running in docker
|
||||
|
||||
return os.environ.get('INVENTREE_PKG_TARGET', None)
|
||||
|
||||
|
||||
def inventreePlatform():
|
||||
"""Returns the platform for the instance."""
|
||||
|
||||
return platform.platform(aliased=True)
|
||||
|
@ -27,12 +27,11 @@ from allauth.account.views import (EmailView, LoginView,
|
||||
PasswordResetFromKeyView)
|
||||
from allauth.socialaccount.forms import DisconnectForm
|
||||
from allauth.socialaccount.views import ConnectionsView
|
||||
from allauth_2fa.views import TwoFactorRemove
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
|
||||
|
||||
from common.models import ColorTheme, InvenTreeSetting
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
import common.models as common_models
|
||||
import common.settings as common_settings
|
||||
from part.models import PartCategory
|
||||
from users.models import RuleSet, check_user_role
|
||||
|
||||
@ -514,10 +513,10 @@ class SettingsView(TemplateView):
|
||||
"""Add data for template."""
|
||||
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["currencies"] = currency_codes
|
||||
ctx["base_currency"] = common_settings.currency_code_default()
|
||||
ctx["currencies"] = common_settings.currency_codes
|
||||
|
||||
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
|
||||
|
||||
@ -525,7 +524,9 @@ class SettingsView(TemplateView):
|
||||
|
||||
# When were the rates last updated?
|
||||
try:
|
||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
|
||||
if backend.exists():
|
||||
backend = backend.first()
|
||||
ctx["rates_updated"] = backend.last_update
|
||||
except Exception:
|
||||
ctx["rates_updated"] = None
|
||||
@ -620,8 +621,8 @@ class AppearanceSelectView(RedirectView):
|
||||
def get_user_theme(self):
|
||||
"""Get current user color theme."""
|
||||
try:
|
||||
user_theme = ColorTheme.objects.filter(user=self.request.user).get()
|
||||
except ColorTheme.DoesNotExist:
|
||||
user_theme = common_models.ColorTheme.objects.filter(user=self.request.user).get()
|
||||
except common_models.ColorTheme.DoesNotExist:
|
||||
user_theme = None
|
||||
|
||||
return user_theme
|
||||
@ -635,7 +636,7 @@ class AppearanceSelectView(RedirectView):
|
||||
|
||||
# Create theme entry if user did not select one yet
|
||||
if not user_theme:
|
||||
user_theme = ColorTheme()
|
||||
user_theme = common_models.ColorTheme()
|
||||
user_theme.user = request.user
|
||||
|
||||
user_theme.name = theme
|
||||
@ -662,9 +663,3 @@ class NotificationsView(TemplateView):
|
||||
"""View for showing notifications."""
|
||||
|
||||
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 import widgets
|
||||
|
||||
from build.models import Build, BuildItem
|
||||
from build.models import Build, BuildLine, BuildItem
|
||||
from InvenTree.admin import InvenTreeResource
|
||||
import part.models
|
||||
|
||||
@ -87,18 +87,33 @@ class BuildItemAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the BuildItem model via the admin interface"""
|
||||
|
||||
list_display = (
|
||||
'build',
|
||||
'stock_item',
|
||||
'quantity'
|
||||
)
|
||||
|
||||
autocomplete_fields = [
|
||||
'build',
|
||||
'bom_item',
|
||||
'build_line',
|
||||
'stock_item',
|
||||
'install_into',
|
||||
]
|
||||
|
||||
|
||||
class BuildLineAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the BuildLine model via the admin interface"""
|
||||
|
||||
list_display = (
|
||||
'build',
|
||||
'bom_item',
|
||||
'quantity',
|
||||
)
|
||||
|
||||
search_fields = [
|
||||
'build__title',
|
||||
'build__reference',
|
||||
'bom_item__sub_part__name',
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(Build, BuildAdmin)
|
||||
admin.site.register(BuildItem, BuildItemAdmin)
|
||||
admin.site.register(BuildLine, BuildLineAdmin)
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""JSON API for the Build app."""
|
||||
|
||||
from django.db.models import F, Q
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
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 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.status_codes import BuildStatus
|
||||
from InvenTree.status_codes import BuildStatus, BuildStatusGroups
|
||||
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
|
||||
|
||||
import common.models
|
||||
import build.admin
|
||||
import build.serializers
|
||||
from build.models import Build, BuildItem, BuildOrderAttachment
|
||||
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
|
||||
import part.models
|
||||
from users.models import Owner
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
|
||||
@ -41,9 +44,9 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
def filter_active(self, queryset, name, value):
|
||||
"""Filter the queryset to either include or exclude orders which are active."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
|
||||
return queryset.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
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')
|
||||
|
||||
@ -87,6 +90,21 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
lookup_expr="iexact"
|
||||
)
|
||||
|
||||
project_code = rest_filters.ModelChoiceFilter(
|
||||
queryset=common.models.ProjectCode.objects.all(),
|
||||
field_name='project_code'
|
||||
)
|
||||
|
||||
has_project_code = rest_filters.BooleanFilter(label='has_project_code', method='filter_has_project_code')
|
||||
|
||||
def filter_has_project_code(self, queryset, name, value):
|
||||
"""Filter by whether or not the order has a project code"""
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.exclude(project_code=None)
|
||||
else:
|
||||
return queryset.filter(project_code=None)
|
||||
|
||||
|
||||
class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of Build objects.
|
||||
@ -112,11 +130,13 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
'completed',
|
||||
'issued_by',
|
||||
'responsible',
|
||||
'project_code',
|
||||
'priority',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'reference': ['reference_int', 'reference'],
|
||||
'project_code': ['project_code__code'],
|
||||
}
|
||||
|
||||
ordering = '-reference'
|
||||
@ -127,6 +147,7 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
'part__name',
|
||||
'part__IPN',
|
||||
'part__description',
|
||||
'project_code__code',
|
||||
'priority',
|
||||
]
|
||||
|
||||
@ -250,6 +271,103 @@ class BuildUnallocate(CreateAPI):
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildLineFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for the BuildLine API endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Meta information for the BuildLineFilter class."""
|
||||
model = BuildLine
|
||||
fields = [
|
||||
'build',
|
||||
'bom_item',
|
||||
]
|
||||
|
||||
# Fields on related models
|
||||
consumable = rest_filters.BooleanFilter(label=_('Consumable'), field_name='bom_item__consumable')
|
||||
optional = rest_filters.BooleanFilter(label=_('Optional'), field_name='bom_item__optional')
|
||||
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
|
||||
|
||||
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
|
||||
|
||||
def filter_allocated(self, queryset, name, value):
|
||||
"""Filter by whether each BuildLine is fully allocated"""
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(allocated__gte=F('quantity'))
|
||||
else:
|
||||
return queryset.filter(allocated__lt=F('quantity'))
|
||||
|
||||
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:
|
||||
"""Mixin class which adds build order as serializer context variable."""
|
||||
|
||||
@ -372,9 +490,8 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
"""Metaclass option"""
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'build',
|
||||
'build_line',
|
||||
'stock_item',
|
||||
'bom_item',
|
||||
'install_into',
|
||||
]
|
||||
|
||||
@ -383,6 +500,11 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
field_name='stock_item__part',
|
||||
)
|
||||
|
||||
build = rest_filters.ModelChoiceFilter(
|
||||
queryset=build.models.Build.objects.all(),
|
||||
field_name='build_line__build',
|
||||
)
|
||||
|
||||
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
|
||||
|
||||
def filter_tracked(self, queryset, name, value):
|
||||
@ -408,10 +530,9 @@ class BuildItemList(ListCreateAPI):
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['build_detail'] = str2bool(params.get('build_detail', False))
|
||||
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
|
||||
kwargs['stock_detail'] = str2bool(params.get('stock_detail', True))
|
||||
for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']:
|
||||
if key in params:
|
||||
kwargs[key] = str2bool(params.get(key, False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@ -422,9 +543,8 @@ class BuildItemList(ListCreateAPI):
|
||||
queryset = BuildItem.objects.all()
|
||||
|
||||
queryset = queryset.select_related(
|
||||
'bom_item',
|
||||
'bom_item__sub_part',
|
||||
'build',
|
||||
'build_line',
|
||||
'build_line__build',
|
||||
'install_into',
|
||||
'stock_item',
|
||||
'stock_item__location',
|
||||
@ -434,7 +554,7 @@ class BuildItemList(ListCreateAPI):
|
||||
return 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)
|
||||
|
||||
params = self.request.query_params
|
||||
@ -486,6 +606,12 @@ build_api_urls = [
|
||||
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
|
||||
])),
|
||||
|
||||
# Build lines
|
||||
re_path(r'^line/', include([
|
||||
path(r'<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
|
||||
re_path(r'^.*$', BuildLineList.as_view(), name='api-build-line-list'),
|
||||
])),
|
||||
|
||||
# Build Items
|
||||
re_path(r'^item/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
|
28
InvenTree/build/migrations/0043_buildline.py
Normal file
28
InvenTree/build/migrations/0043_buildline.py
Normal file
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
97
InvenTree/build/migrations/0044_auto_20230528_1410.py
Normal file
97
InvenTree/build/migrations/0044_auto_20230528_1410.py
Normal file
@ -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,
|
||||
),
|
||||
]
|
19
InvenTree/build/migrations/0045_builditem_build_line.py
Normal file
19
InvenTree/build/migrations/0045_builditem_build_line.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
95
InvenTree/build/migrations/0046_auto_20230606_1033.py
Normal file
95
InvenTree/build/migrations/0046_auto_20230606_1033.py
Normal file
@ -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,
|
||||
)
|
||||
]
|
26
InvenTree/build/migrations/0047_auto_20230606_1058.py
Normal file
26
InvenTree/build/migrations/0047_auto_20230606_1058.py
Normal file
@ -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',
|
||||
),
|
||||
]
|
20
InvenTree/build/migrations/0048_build_project_code.py
Normal file
20
InvenTree/build/migrations/0048_build_project_code.py
Normal file
@ -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."""
|
||||
|
||||
import decimal
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
@ -21,7 +21,7 @@ from mptt.exceptions import InvalidMove
|
||||
|
||||
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
|
||||
|
||||
@ -32,14 +32,18 @@ import InvenTree.models
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
|
||||
import common.models
|
||||
from common.notifications import trigger_notification
|
||||
from plugin.events import trigger_event
|
||||
|
||||
import common.notifications
|
||||
import part.models
|
||||
import stock.models
|
||||
import users.models
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@ -69,7 +73,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
verbose_name = _("Build Order")
|
||||
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
|
||||
REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
|
||||
@ -129,10 +133,10 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
return queryset
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
@ -231,7 +235,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
|
||||
status = models.PositiveIntegerField(
|
||||
verbose_name=_('Build Status'),
|
||||
default=BuildStatus.PENDING,
|
||||
default=BuildStatus.PENDING.value,
|
||||
choices=BuildStatus.items(),
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text=_('Build status code')
|
||||
@ -298,6 +302,14 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
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):
|
||||
"""Return all Build Order objects under this one."""
|
||||
if cascade:
|
||||
@ -331,36 +343,32 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
@property
|
||||
def active(self):
|
||||
"""Return True if this build is active."""
|
||||
return self.status in BuildStatus.ACTIVE_CODES
|
||||
return self.status in BuildStatusGroups.ACTIVE_CODES
|
||||
|
||||
@property
|
||||
def bom_items(self):
|
||||
"""Returns the BOM items for the part referenced by this BuildOrder."""
|
||||
return self.part.get_bom_items()
|
||||
def tracked_line_items(self):
|
||||
"""Returns the "trackable" BOM lines for this BuildOrder."""
|
||||
|
||||
@property
|
||||
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 self.build_lines.filter(bom_item__sub_part__trackable=True)
|
||||
|
||||
return items
|
||||
|
||||
def has_tracked_bom_items(self):
|
||||
def has_tracked_line_items(self):
|
||||
"""Returns True if this BuildOrder has trackable BomItems."""
|
||||
return self.tracked_bom_items.count() > 0
|
||||
return self.tracked_line_items.count() > 0
|
||||
|
||||
@property
|
||||
def untracked_bom_items(self):
|
||||
def untracked_line_items(self):
|
||||
"""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."""
|
||||
return self.untracked_bom_items.count() > 0
|
||||
return self.has_untracked_line_items.count() > 0
|
||||
|
||||
@property
|
||||
def remaining(self):
|
||||
@ -422,6 +430,11 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
|
||||
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
|
||||
def incomplete_outputs(self):
|
||||
"""Return all the "incomplete" build outputs."""
|
||||
@ -478,21 +491,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
|
||||
@property
|
||||
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
|
||||
- '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:
|
||||
return False
|
||||
|
||||
if self.remaining > 0:
|
||||
return False
|
||||
|
||||
if not self.are_untracked_parts_allocated():
|
||||
if not self.is_fully_allocated(tracked=False):
|
||||
return False
|
||||
|
||||
# No issues!
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
@ -503,7 +517,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
|
||||
self.completion_date = datetime.now().date()
|
||||
self.completed_by = user
|
||||
self.status = BuildStatus.COMPLETE
|
||||
self.status = BuildStatus.COMPLETE.value
|
||||
self.save()
|
||||
|
||||
# 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
|
||||
# which point to this Build Order
|
||||
self.allocated_stock.all().delete()
|
||||
self.allocated_stock.delete()
|
||||
|
||||
# Register an event
|
||||
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.completed',
|
||||
targets=targets,
|
||||
@ -566,13 +580,14 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
||||
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
|
||||
|
||||
# Handle stock allocations
|
||||
for build_item in self.allocated_stock.all():
|
||||
# Find all BuildItem objects associated with this Build
|
||||
items = self.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)
|
||||
if remove_incomplete_outputs:
|
||||
@ -585,26 +600,25 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
self.completion_date = datetime.now().date()
|
||||
self.completed_by = user
|
||||
|
||||
self.status = BuildStatus.CANCELLED
|
||||
self.status = BuildStatus.CANCELLED.value
|
||||
self.save()
|
||||
|
||||
trigger_event('build.cancelled', id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def unallocateStock(self, bom_item=None, output=None):
|
||||
"""Unallocate stock from this Build.
|
||||
def deallocate_stock(self, build_line=None, output=None):
|
||||
"""Deallocate stock from this Build.
|
||||
|
||||
Args:
|
||||
bom_item: Specify a particular BomItem to unallocate stock against
|
||||
output: Specify a particular StockItem (output) to unallocate stock against
|
||||
build_line: Specify a particular BuildLine instance to un-allocate stock against
|
||||
output: Specify a particular StockItem (output) to un-allocate stock against
|
||||
"""
|
||||
allocations = BuildItem.objects.filter(
|
||||
build=self,
|
||||
allocations = self.allocated_stock.filter(
|
||||
install_into=output
|
||||
)
|
||||
|
||||
if bom_item:
|
||||
allocations = allocations.filter(bom_item=bom_item)
|
||||
if build_line:
|
||||
allocations = allocations.filter(build_line=build_line)
|
||||
|
||||
allocations.delete()
|
||||
|
||||
@ -729,7 +743,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
_add_tracking_entry(output, user)
|
||||
|
||||
if self.status == BuildStatus.PENDING:
|
||||
self.status = BuildStatus.PRODUCTION
|
||||
self.status = BuildStatus.PRODUCTION.value
|
||||
self.save()
|
||||
|
||||
@transaction.atomic
|
||||
@ -737,7 +751,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
"""Remove a build output from the database.
|
||||
|
||||
Executes:
|
||||
- Unallocate any build items against the output
|
||||
- Deallocate any build items against the output
|
||||
- Delete the output StockItem
|
||||
"""
|
||||
if not output:
|
||||
@ -749,8 +763,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
if output.build != self:
|
||||
raise ValidationError(_("Build output does not match Build Order"))
|
||||
|
||||
# Unallocate all build items against the output
|
||||
self.unallocateStock(output=output)
|
||||
# Deallocate all build items against the output
|
||||
self.deallocate_stock(output=output)
|
||||
|
||||
# Remove the build output from the database
|
||||
output.delete()
|
||||
@ -758,36 +772,47 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
@transaction.atomic
|
||||
def trim_allocated_stock(self):
|
||||
"""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
|
||||
for bom_item in self.untracked_bom_items:
|
||||
reduce_by = self.allocated_quantity(bom_item) - self.required_quantity(bom_item)
|
||||
if reduce_by <= 0:
|
||||
continue # all OK
|
||||
for build_line in self.untracked_line_items:
|
||||
|
||||
reduce_by = build_line.allocated_quantity() - build_line.quantity
|
||||
|
||||
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
|
||||
if reduce_by == 0:
|
||||
if reduce_by <= 0:
|
||||
break
|
||||
|
||||
# Easy case - this item can just be reduced.
|
||||
if a.quantity > reduce_by:
|
||||
a.quantity -= reduce_by
|
||||
a.save()
|
||||
if item.quantity > reduce_by:
|
||||
item.quantity -= reduce_by
|
||||
item.save()
|
||||
break
|
||||
|
||||
# Harder case, this item needs to be deleted, and any remainder
|
||||
# taken from the next items in the list.
|
||||
reduce_by -= a.quantity
|
||||
a.delete()
|
||||
reduce_by -= item.quantity
|
||||
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
|
||||
def subtract_allocated_stock(self, user):
|
||||
"""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(
|
||||
stock_item__part__trackable=False
|
||||
build_line__bom_item__sub_part__trackable=False
|
||||
)
|
||||
|
||||
# Remove stock
|
||||
@ -831,7 +856,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
|
||||
# Update build output item
|
||||
output.is_building = False
|
||||
output.status = StockStatus.REJECTED
|
||||
output.status = StockStatus.REJECTED.value
|
||||
output.location = location
|
||||
output.save(add_note=False)
|
||||
|
||||
@ -851,7 +876,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
notes=notes,
|
||||
deltas={
|
||||
'location': location.pk,
|
||||
'status': StockStatus.REJECTED,
|
||||
'status': StockStatus.REJECTED.value,
|
||||
'buildorder': self.pk,
|
||||
}
|
||||
)
|
||||
@ -865,7 +890,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
"""
|
||||
# Select the location for the build output
|
||||
location = kwargs.get('location', self.destination)
|
||||
status = kwargs.get('status', StockStatus.OK)
|
||||
status = kwargs.get('status', StockStatus.OK.value)
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
# List the allocated BuildItem objects for the given output
|
||||
@ -934,8 +959,13 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
else:
|
||||
return 3
|
||||
|
||||
# Get a list of all 'untracked' BOM items
|
||||
for bom_item in self.untracked_bom_items:
|
||||
new_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:
|
||||
# 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)
|
||||
|
||||
unallocated_quantity = self.unallocated_quantity(bom_item)
|
||||
unallocated_quantity = line_item.unallocated_quantity()
|
||||
|
||||
if unallocated_quantity <= 0:
|
||||
# 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
|
||||
|
||||
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?
|
||||
quantity = min(unallocated_quantity, stock_item.unallocated_quantity())
|
||||
|
||||
if quantity > 0:
|
||||
|
||||
try:
|
||||
BuildItem.objects.create(
|
||||
build=self,
|
||||
bom_item=bom_item,
|
||||
new_items.append(BuildItem(
|
||||
build_line=line_item,
|
||||
stock_item=stock_item,
|
||||
quantity=quantity,
|
||||
)
|
||||
))
|
||||
|
||||
# Subtract the required 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!
|
||||
break
|
||||
|
||||
def required_quantity(self, bom_item, output=None):
|
||||
"""Get the quantity of a part required to complete the particular build output.
|
||||
# Bulk-create the new BuildItem objects
|
||||
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:
|
||||
bom_item: The Part object
|
||||
output: The particular build output (StockItem)
|
||||
output: StockItem object
|
||||
|
||||
To determine if the output has been fully allocated,
|
||||
we need to test all "trackable" BuildLine objects
|
||||
"""
|
||||
quantity = bom_item.quantity
|
||||
|
||||
if output:
|
||||
quantity *= output.quantity
|
||||
else:
|
||||
quantity *= self.quantity
|
||||
|
||||
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).
|
||||
"""
|
||||
for line in self.build_lines.filter(bom_item__sub_part__trackable=True):
|
||||
# Grab all BuildItem objects which point to this output
|
||||
allocations = BuildItem.objects.filter(
|
||||
build=self,
|
||||
bom_item=bom_item,
|
||||
build_line=line,
|
||||
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(),
|
||||
)
|
||||
q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
|
||||
)
|
||||
|
||||
return allocated['q']
|
||||
|
||||
def unallocated_quantity(self, bom_item, output=None):
|
||||
"""Return the total unallocated (remaining) quantity of a part against a particular output."""
|
||||
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):
|
||||
# The amount allocated against an output must at least equal the BOM quantity
|
||||
if allocated['q'] < line.bom_item.quantity:
|
||||
return False
|
||||
|
||||
# All parts must be fully allocated!
|
||||
# At this stage, we can assume that the output is fully allocated
|
||||
return True
|
||||
|
||||
def is_partially_allocated(self, output):
|
||||
"""Returns True if the particular build output is (at least) partially 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
|
||||
def is_overallocated(self):
|
||||
"""Test if the BuildOrder has been over-allocated.
|
||||
|
||||
for bom_item in bom_items:
|
||||
|
||||
if self.allocated_quantity(bom_item, output) > 0:
|
||||
return True
|
||||
|
||||
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
|
||||
Returns:
|
||||
True if any BuildLine has been over-allocated.
|
||||
"""
|
||||
|
||||
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):
|
||||
for line in self.build_lines.all():
|
||||
if line.is_overallocated():
|
||||
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
|
||||
def is_active(self):
|
||||
"""Is this build active?
|
||||
@ -1187,13 +1141,59 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
- PENDING
|
||||
- HOLDING
|
||||
"""
|
||||
return self.status in BuildStatus.ACTIVE_CODES
|
||||
return self.status in BuildStatusGroups.ACTIVE_CODES
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
"""Returns True if the build status is 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')
|
||||
def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||
@ -1204,15 +1204,24 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||
|
||||
from . import tasks as build_tasks
|
||||
|
||||
if instance:
|
||||
|
||||
if created:
|
||||
# A new Build has just been created
|
||||
|
||||
# Generate initial BuildLine objects for the Build
|
||||
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):
|
||||
"""Model for storing file attachments against a BuildOrder object."""
|
||||
@ -1224,6 +1233,87 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
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):
|
||||
"""A BuildItem links multiple StockItem objects to a Build.
|
||||
|
||||
@ -1231,16 +1321,16 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
|
||||
|
||||
Attributes:
|
||||
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
|
||||
quantity: Number of units allocated
|
||||
install_into: Destination stock item (or None)
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
"""Model meta options"""
|
||||
unique_together = [
|
||||
('build', 'stock_item', 'install_into'),
|
||||
('build_line', 'stock_item', 'install_into'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@ -1303,8 +1393,10 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
|
||||
'quantity': _('Quantity must be 1 for serialized stock')
|
||||
})
|
||||
|
||||
except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist):
|
||||
pass
|
||||
except stock.models.StockItem.DoesNotExist:
|
||||
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.
|
||||
@ -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!
|
||||
"""
|
||||
|
||||
bom_item_valid = False
|
||||
valid = False
|
||||
|
||||
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:
|
||||
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:
|
||||
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 not bom_item_valid:
|
||||
if not valid:
|
||||
|
||||
if self.build and self.stock_item:
|
||||
ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True)
|
||||
|
||||
for idx, ancestor in enumerate(ancestors):
|
||||
|
||||
try:
|
||||
bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
|
||||
except part.models.BomItem.DoesNotExist:
|
||||
continue
|
||||
build_line = BuildLine.objects.filter(
|
||||
build=self.build,
|
||||
bom_item__part=ancestor,
|
||||
)
|
||||
|
||||
# A matching BOM item has been found!
|
||||
if idx == 0 or bom_item.allow_variants:
|
||||
bom_item_valid = True
|
||||
self.bom_item = bom_item
|
||||
if build_line.exists():
|
||||
line = build_line.first()
|
||||
|
||||
if idx == 0 or line.bom_item.allow_variants:
|
||||
valid = True
|
||||
self.build_line = line
|
||||
break
|
||||
|
||||
# BomItem did not exist or could not be validated.
|
||||
# Search for a new one
|
||||
if not bom_item_valid:
|
||||
if not valid:
|
||||
|
||||
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
|
||||
def complete_allocation(self, user, notes=''):
|
||||
"""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):
|
||||
"""Return qualified URL for part thumbnail image."""
|
||||
thumb_url = None
|
||||
|
||||
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,
|
||||
build_line = models.ForeignKey(
|
||||
BuildLine,
|
||||
on_delete=models.SET_NULL, null=True,
|
||||
related_name='allocations',
|
||||
)
|
||||
|
||||
stock_item = models.ForeignKey(
|
||||
|
@ -4,8 +4,11 @@ from django.db import transaction
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
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.functions import Coalesce
|
||||
|
||||
from rest_framework import serializers
|
||||
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.serializers import StockItemSerializerBrief, LocationSerializer
|
||||
|
||||
from part.models import BomItem
|
||||
from part.serializers import PartSerializer, PartBriefSerializer
|
||||
from common.serializers import ProjectCodeSerializer
|
||||
import part.filters
|
||||
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
from .models import Build, BuildItem, BuildOrderAttachment
|
||||
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
|
||||
|
||||
|
||||
class BuildSerializer(InvenTreeModelSerializer):
|
||||
@ -46,6 +50,8 @@ class BuildSerializer(InvenTreeModelSerializer):
|
||||
'parent',
|
||||
'part',
|
||||
'part_detail',
|
||||
'project_code',
|
||||
'project_code_detail',
|
||||
'overdue',
|
||||
'reference',
|
||||
'sales_order',
|
||||
@ -87,6 +93,8 @@ class BuildSerializer(InvenTreeModelSerializer):
|
||||
|
||||
barcode_hash = serializers.CharField(read_only=True)
|
||||
|
||||
project_code_detail = ProjectCodeSerializer(source='project_code', many=False, read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
|
||||
@ -170,7 +178,7 @@ class BuildOutputSerializer(serializers.Serializer):
|
||||
if to_complete:
|
||||
|
||||
# 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
|
||||
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(
|
||||
choices=list(StockStatus.items()),
|
||||
default=StockStatus.OK,
|
||||
choices=StockStatus.items(),
|
||||
default=StockStatus.OK.value,
|
||||
label=_("Status"),
|
||||
)
|
||||
|
||||
@ -562,7 +570,7 @@ class BuildCancelSerializer(serializers.Serializer):
|
||||
build = self.context['build']
|
||||
|
||||
return {
|
||||
'has_allocated_stock': build.is_partially_allocated(None),
|
||||
'has_allocated_stock': build.is_partially_allocated(),
|
||||
'incomplete_outputs': build.incomplete_count,
|
||||
'completed_outputs': build.complete_count,
|
||||
}
|
||||
@ -621,8 +629,8 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
build = self.context['build']
|
||||
|
||||
return {
|
||||
'overallocated': build.has_overallocated_parts(),
|
||||
'allocated': build.are_untracked_parts_allocated(),
|
||||
'overallocated': build.is_overallocated(),
|
||||
'allocated': build.are_untracked_parts_allocated,
|
||||
'remaining': build.remaining,
|
||||
'incomplete': build.incomplete_count,
|
||||
}
|
||||
@ -639,7 +647,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""Check if the 'accept_overallocated' field is required"""
|
||||
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'))
|
||||
|
||||
return value
|
||||
@ -655,7 +663,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""Check if the 'accept_unallocated' field is required"""
|
||||
build = self.context['build']
|
||||
|
||||
if not build.are_untracked_parts_allocated() and not value:
|
||||
if not build.are_untracked_parts_allocated and not value:
|
||||
raise ValidationError(_('Required stock has not been fully allocated'))
|
||||
|
||||
return value
|
||||
@ -706,12 +714,12 @@ class BuildUnallocationSerializer(serializers.Serializer):
|
||||
- bom_item: Filter against a particular BOM line item
|
||||
"""
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BomItem.objects.all(),
|
||||
build_line = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BuildLine.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('BOM Item'),
|
||||
label=_('Build Line'),
|
||||
)
|
||||
|
||||
output = serializers.PrimaryKeyRelatedField(
|
||||
@ -742,8 +750,8 @@ class BuildUnallocationSerializer(serializers.Serializer):
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
build.unallocateStock(
|
||||
bom_item=data['bom_item'],
|
||||
build.deallocate_stock(
|
||||
build_line=data['build_line'],
|
||||
output=data['output']
|
||||
)
|
||||
|
||||
@ -754,34 +762,34 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = [
|
||||
'bom_item',
|
||||
'build_item',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
'output',
|
||||
]
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BomItem.objects.all(),
|
||||
build_line = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BuildLine.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
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"""
|
||||
build = self.context['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 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
|
||||
else:
|
||||
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(
|
||||
queryset=StockItem.objects.all(),
|
||||
@ -824,8 +832,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
"""Perform data validation for this item"""
|
||||
super().validate(data)
|
||||
|
||||
build = self.context['build']
|
||||
bom_item = data['bom_item']
|
||||
build_line = data['build_line']
|
||||
stock_item = data['stock_item']
|
||||
quantity = data['quantity']
|
||||
output = data.get('output', None)
|
||||
@ -847,20 +854,20 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
})
|
||||
|
||||
# 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({
|
||||
'output': _('Build output must be specified for allocation of tracked parts'),
|
||||
})
|
||||
|
||||
# Output *cannot* be set for un-tracked parts
|
||||
if output is not None and not bom_item.sub_part.trackable:
|
||||
if output is not None and not build_line.bom_item.sub_part.trackable:
|
||||
|
||||
raise ValidationError({
|
||||
'output': _('Build output cannot be specified for allocation of untracked parts'),
|
||||
})
|
||||
|
||||
# Check if this allocation would be unique
|
||||
if BuildItem.objects.filter(build=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'))
|
||||
|
||||
return data
|
||||
@ -894,24 +901,21 @@ class BuildAllocationSerializer(serializers.Serializer):
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
bom_item = item['bom_item']
|
||||
build_line = item['build_line']
|
||||
stock_item = item['stock_item']
|
||||
quantity = item['quantity']
|
||||
output = item.get('output', None)
|
||||
|
||||
# Ignore allocation for consumable BOM items
|
||||
if bom_item.consumable:
|
||||
if build_line.bom_item.consumable:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Create a new BuildItem to allocate stock
|
||||
BuildItem.objects.create(
|
||||
build=build,
|
||||
bom_item=bom_item,
|
||||
build_line=build_line,
|
||||
stock_item=stock_item,
|
||||
quantity=quantity,
|
||||
install_into=output
|
||||
@ -993,43 +997,37 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'pk',
|
||||
'bom_part',
|
||||
'build',
|
||||
'build_detail',
|
||||
'build_line',
|
||||
'install_into',
|
||||
'location',
|
||||
'location_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
'location_detail',
|
||||
'part_detail',
|
||||
'stock_item_detail',
|
||||
'quantity'
|
||||
'build_detail',
|
||||
]
|
||||
|
||||
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
|
||||
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
|
||||
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
|
||||
# Annotated fields
|
||||
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
# Extra (optional) detail fields
|
||||
part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True)
|
||||
build_detail = BuildSerializer(source='build', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
|
||||
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
|
||||
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Determine which extra details fields should be included"""
|
||||
build_detail = kwargs.pop('build_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
location_detail = kwargs.pop('location_detail', True)
|
||||
stock_detail = kwargs.pop('stock_detail', False)
|
||||
build_detail = kwargs.pop('build_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not build_detail:
|
||||
self.fields.pop('build_detail')
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
@ -1039,6 +1037,166 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
if not stock_detail:
|
||||
self.fields.pop('stock_item_detail')
|
||||
|
||||
if not build_detail:
|
||||
self.fields.pop('build_detail')
|
||||
|
||||
|
||||
class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for a BuildItem object."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
|
||||
model = BuildLine
|
||||
fields = [
|
||||
'pk',
|
||||
'build',
|
||||
'bom_item',
|
||||
'bom_item_detail',
|
||||
'part_detail',
|
||||
'quantity',
|
||||
'allocations',
|
||||
|
||||
# Annotated fields
|
||||
'allocated',
|
||||
'on_order',
|
||||
'available_stock',
|
||||
'available_substitute_stock',
|
||||
'available_variant_stock',
|
||||
'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):
|
||||
"""Serializer for a BuildAttachment."""
|
||||
|
@ -15,7 +15,7 @@ import build.models
|
||||
import InvenTree.email
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.tasks
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.status_codes import BuildStatusGroups
|
||||
from InvenTree.ready import isImportingData
|
||||
|
||||
import part.models as part_models
|
||||
@ -158,7 +158,7 @@ def check_overdue_build_orders():
|
||||
|
||||
overdue_orders = build.models.Build.objects.filter(
|
||||
target_date=yesterday,
|
||||
status__in=BuildStatus.ACTIVE_CODES
|
||||
status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
)
|
||||
|
||||
for bo in overdue_orders:
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load status_codes %}
|
||||
{% load generic %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block page_title %}
|
||||
@ -108,6 +108,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{% trans "Build Description" %}</td>
|
||||
<td>{{ build.title }}</td>
|
||||
</tr>
|
||||
{% include "project_code_data.html" with instance=build %}
|
||||
{% include "barcode_data.html" with instance=build %}
|
||||
</table>
|
||||
|
||||
@ -150,7 +151,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>
|
||||
{% build_status_label build.status %}
|
||||
{% status_label 'build' build.status %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if build.target_date %}
|
||||
@ -174,7 +175,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% else %}
|
||||
<span class='fa fa-times-circle icon-red'></span>
|
||||
{% endif %}
|
||||
<td>{% trans "Completed" %}</td>
|
||||
<td>{% trans "Completed Outputs" %}</td>
|
||||
<td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td>
|
||||
</tr>
|
||||
{% if build.parent %}
|
||||
@ -217,7 +218,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
{% block page_data %}
|
||||
<h3>
|
||||
{% build_status_label build.status large=True %}
|
||||
{% status_label 'build' build.status large=True %}
|
||||
{% if build.is_overdue %}
|
||||
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
|
||||
{% endif %}
|
||||
|
@ -2,7 +2,7 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% load status_codes %}
|
||||
{% load generic %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "build/sidebar.html" %}
|
||||
@ -60,14 +60,14 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>{% build_status_label build.status %}</td>
|
||||
<td>{% status_label 'build' build.status %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
{% if build.active and has_untracked_bom_items %}
|
||||
{% if build.active %}
|
||||
<tr>
|
||||
<td><span class='fas fa-list'></span></td>
|
||||
<td>{% trans "Allocated Parts" %}</td>
|
||||
@ -165,10 +165,8 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='child-button-toolbar'>
|
||||
<div class='button-toolbar container-fluid float-right'>
|
||||
{% include "filter_list.html" with id='sub-build' %}
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#child-button-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
@ -179,9 +177,9 @@
|
||||
<h4>{% trans "Allocate Stock to Build" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.build.add and build.active and has_untracked_bom_items %}
|
||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||
{% if roles.build.add and build.active %}
|
||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Deallocate stock" %}'>
|
||||
<span class='fas fa-minus-circle'></span> {% trans "Deallocate Stock" %}
|
||||
</button>
|
||||
<button class='btn btn-primary' type='button' id='btn-auto-allocate' title='{% trans "Automatically allocate stock to build" %}'>
|
||||
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
|
||||
@ -199,34 +197,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if has_untracked_bom_items %}
|
||||
{% if build.active %}
|
||||
{% if build.are_untracked_parts_allocated %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Untracked stock has been fully allocated for this Build Order" %}
|
||||
<div id='build-lines-toolbar'>
|
||||
{% include "filter_list.html" with id='buildlines' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "Untracked stock has not been fully allocated for this Build Order" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div id='unallocated-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span>
|
||||
</button>
|
||||
{% include "filter_list.html" with id='builditems' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='allocation-table-untracked' data-toolbar='#unallocated-toolbar'></table>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "This Build Order does not have any associated untracked BOM items" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -246,38 +220,8 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='build-output-toolbar'>
|
||||
<div class='button-toolbar container-fluid'>
|
||||
{% if build.active %}
|
||||
<div class='btn-group'>
|
||||
|
||||
<!-- Build output actions -->
|
||||
<div class='btn-group'>
|
||||
<button id='output-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Output Actions" %}'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
{% if roles.build.add %}
|
||||
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected build outputs" %}'>
|
||||
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
{% if roles.build.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>
|
||||
<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 }},
|
||||
is_building: false,
|
||||
},
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@ -427,38 +368,15 @@ onPanelLoad('outputs', function() {
|
||||
{% 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() {
|
||||
loadUntrackedStockTable();
|
||||
// Load the table of line items for this build order
|
||||
loadBuildLineTable(
|
||||
"#build-lines-table",
|
||||
{{ build.pk }},
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
$('#btn-create-output').click(function() {
|
||||
|
||||
createBuildOutput(
|
||||
@ -480,66 +398,58 @@ $("#btn-auto-allocate").on('click', function() {
|
||||
{% if build.take_from %}
|
||||
location: {{ build.take_from.pk }},
|
||||
{% 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) {
|
||||
if (bom_item.required > bom_item.allocated) {
|
||||
incomplete_bom_items.push(bom_item);
|
||||
data.forEach(function(line) {
|
||||
if (line.allocated < line.quantity) {
|
||||
unallocated_lines.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
if (incomplete_bom_items.length == 0) {
|
||||
if (unallocated_lines.length == 0) {
|
||||
showAlertDialog(
|
||||
'{% trans "Allocation Complete" %}',
|
||||
'{% trans "All untracked stock items have been allocated" %}',
|
||||
'{% trans "All lines have been fully allocated" %}',
|
||||
);
|
||||
} else {
|
||||
|
||||
allocateStockToBuild(
|
||||
{{ build.pk }},
|
||||
{{ build.part.pk }},
|
||||
incomplete_bom_items,
|
||||
unallocated_lines,
|
||||
{
|
||||
{% if build.take_from %}
|
||||
source_location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
success: loadUntrackedStockTable,
|
||||
success: function() {
|
||||
$('#build-lines-table').bootstrapTable('refresh');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#btn-unallocate').on('click', function() {
|
||||
unallocateStock({{ build.id }}, {
|
||||
deallocateStock({{ build.id }}, {
|
||||
table: '#allocation-table-untracked',
|
||||
onSuccess: loadUntrackedStockTable,
|
||||
onSuccess: function() {
|
||||
$('#build-lines-table').bootstrapTable('refresh');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$('#allocate-selected-items').click(function() {
|
||||
|
||||
var bom_items = getTableData('#allocation-table-untracked');
|
||||
|
||||
allocateStockToBuild(
|
||||
{{ build.pk }},
|
||||
{{ build.part.pk }},
|
||||
bom_items,
|
||||
{
|
||||
{% if build.take_from %}
|
||||
source_location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
success: loadUntrackedStockTable,
|
||||
}
|
||||
);
|
||||
$("#btn-allocate").on('click', function() {
|
||||
allocateSelectedLines();
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
@ -24,12 +24,8 @@
|
||||
|
||||
<div class='panel-content'>
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="build" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>
|
||||
</table>
|
||||
|
@ -4,18 +4,16 @@
|
||||
|
||||
{% trans "Build Order Details" as text %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
|
||||
{% endif %}
|
||||
{% trans "Completed Outputs" as text %}
|
||||
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
|
||||
{% trans "Consumed Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
|
||||
{% trans "Child Build Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
|
||||
{% trans "Attachments" as text %}
|
||||
|
@ -298,7 +298,7 @@ class BuildTest(BuildAPITest):
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
bo.status = BuildStatus.CANCELLED
|
||||
bo.status = BuildStatus.CANCELLED.value
|
||||
bo.save()
|
||||
|
||||
# Now, we should be able to delete
|
||||
@ -582,6 +582,9 @@ class BuildAllocationTest(BuildAPITest):
|
||||
|
||||
self.build = Build.objects.get(pk=1)
|
||||
|
||||
# Regenerate BuildLine objects
|
||||
self.build.create_build_line_items()
|
||||
|
||||
# Record number of build items which exist at the start of each test
|
||||
self.n = BuildItem.objects.count()
|
||||
|
||||
@ -593,7 +596,7 @@ class BuildAllocationTest(BuildAPITest):
|
||||
self.assertEqual(self.build.part.bom_items.count(), 4)
|
||||
|
||||
# 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):
|
||||
"""A GET request to the endpoint should return an error."""
|
||||
@ -634,7 +637,7 @@ class BuildAllocationTest(BuildAPITest):
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 1, # M2x4 LPHS
|
||||
"build_line": 1, # M2x4 LPHS
|
||||
"stock_item": 2, # 5,000 screws available
|
||||
}
|
||||
]
|
||||
@ -658,7 +661,7 @@ class BuildAllocationTest(BuildAPITest):
|
||||
expected_code=400
|
||||
).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
|
||||
data = self.post(
|
||||
@ -666,7 +669,7 @@ class BuildAllocationTest(BuildAPITest):
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 1,
|
||||
"build_line": 1,
|
||||
"quantity": 5000,
|
||||
}
|
||||
]
|
||||
@ -681,12 +684,25 @@ class BuildAllocationTest(BuildAPITest):
|
||||
|
||||
def test_invalid_bom_item(self):
|
||||
"""Test by passing an invalid BOM item."""
|
||||
|
||||
# Find the right (in this case, wrong) BuildLine instance
|
||||
|
||||
si = StockItem.objects.get(pk=11)
|
||||
lines = self.build.build_lines.all()
|
||||
|
||||
wrong_line = None
|
||||
|
||||
for line in lines:
|
||||
if line.bom_item.sub_part.pk != si.pk:
|
||||
wrong_line = line
|
||||
break
|
||||
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 5,
|
||||
"build_line": wrong_line.pk,
|
||||
"stock_item": 11,
|
||||
"quantity": 500,
|
||||
}
|
||||
@ -695,19 +711,31 @@ class BuildAllocationTest(BuildAPITest):
|
||||
expected_code=400
|
||||
).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):
|
||||
"""Test with valid data.
|
||||
|
||||
This should result in creation of a new BuildItem object
|
||||
"""
|
||||
|
||||
# Find the correct BuildLine
|
||||
si = StockItem.objects.get(pk=2)
|
||||
|
||||
right_line = None
|
||||
|
||||
for line in self.build.build_lines.all():
|
||||
|
||||
if line.bom_item.sub_part.pk == si.part.pk:
|
||||
right_line = line
|
||||
break
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 1,
|
||||
"build_line": right_line.pk,
|
||||
"stock_item": 2,
|
||||
"quantity": 5000,
|
||||
}
|
||||
@ -749,16 +777,22 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
cls.state = {}
|
||||
cls.allocation = {}
|
||||
|
||||
for i, bi in enumerate(cls.build.part.bom_items.all()):
|
||||
rq = cls.build.required_quantity(bi, None) + i + 1
|
||||
si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first()
|
||||
items_to_create = []
|
||||
|
||||
cls.state[bi.sub_part] = (si, si.quantity, rq)
|
||||
BuildItem.objects.create(
|
||||
build=cls.build,
|
||||
for idx, build_line in enumerate(cls.build.build_lines.all()):
|
||||
required = build_line.quantity + idx + 1
|
||||
sub_part = build_line.bom_item.sub_part
|
||||
si = StockItem.objects.filter(part=sub_part, quantity__gte=required).first()
|
||||
|
||||
cls.state[sub_part] = (si, si.quantity, required)
|
||||
|
||||
items_to_create.append(BuildItem(
|
||||
build_line=build_line,
|
||||
stock_item=si,
|
||||
quantity=rq,
|
||||
)
|
||||
quantity=required,
|
||||
))
|
||||
|
||||
BuildItem.objects.bulk_create(items_to_create)
|
||||
|
||||
# create and complete outputs
|
||||
cls.build.create_build_output(cls.build.quantity)
|
||||
@ -822,9 +856,10 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
self.assertTrue(self.build.is_complete)
|
||||
|
||||
# Check stock items have reduced only by bom requirement (overallocation trimmed)
|
||||
for bi in self.build.part.bom_items.all():
|
||||
si, oq, _ = self.state[bi.sub_part]
|
||||
rq = self.build.required_quantity(bi, None)
|
||||
for line in self.build.build_lines.all():
|
||||
|
||||
si, oq, _ = self.state[line.bom_item.sub_part]
|
||||
rq = line.quantity
|
||||
si.refresh_from_db()
|
||||
self.assertEqual(si.quantity, oq - rq)
|
||||
|
||||
@ -843,7 +878,7 @@ class BuildListTest(BuildAPITest):
|
||||
builds = self.get(self.url, data={'active': True})
|
||||
self.assertEqual(len(builds.data), 1)
|
||||
|
||||
builds = self.get(self.url, data={'status': BuildStatus.COMPLETE})
|
||||
builds = self.get(self.url, data={'status': BuildStatus.COMPLETE.value})
|
||||
self.assertEqual(len(builds.data), 4)
|
||||
|
||||
builds = self.get(self.url, data={'overdue': False})
|
||||
@ -863,7 +898,7 @@ class BuildListTest(BuildAPITest):
|
||||
reference="BO-0006",
|
||||
quantity=10,
|
||||
title='Just some thing',
|
||||
status=BuildStatus.PRODUCTION,
|
||||
status=BuildStatus.PRODUCTION.value,
|
||||
target_date=in_the_past
|
||||
)
|
||||
|
||||
|
@ -13,7 +13,7 @@ from InvenTree import status_codes as status
|
||||
|
||||
import common.models
|
||||
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 stock.models import StockItem
|
||||
from users.models import Owner
|
||||
@ -107,6 +107,11 @@ class BuildTestBase(TestCase):
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
)
|
||||
|
||||
# Create some BuildLine items we can use later on
|
||||
cls.line_1 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_1)
|
||||
cls.line_2 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_2)
|
||||
cls.line_3 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_3)
|
||||
|
||||
# Create some build output (StockItem) objects
|
||||
cls.output_1 = StockItem.objects.create(
|
||||
part=cls.assembly,
|
||||
@ -248,13 +253,10 @@ class BuildTest(BuildTestBase):
|
||||
for output in self.build.get_build_outputs().all():
|
||||
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.build.is_bom_item_allocated(self.bom_item_2, self.output_2))
|
||||
self.assertFalse(self.line_1.is_fully_allocated())
|
||||
self.assertFalse(self.line_2.is_overallocated())
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_2), 35)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_1), 9)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_2), 21)
|
||||
self.assertEqual(self.line_1.allocated_quantity(), 0)
|
||||
|
||||
self.assertFalse(self.build.is_complete)
|
||||
|
||||
@ -264,25 +266,25 @@ class BuildTest(BuildTestBase):
|
||||
stock = StockItem.objects.create(part=self.assembly, quantity=99)
|
||||
|
||||
# Create a BuiltItem which points to an invalid StockItem
|
||||
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
|
||||
b = BuildItem(stock_item=stock, build_line=self.line_2, quantity=10)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
b.save()
|
||||
|
||||
# Create a BuildItem which has too much stock assigned
|
||||
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999)
|
||||
b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
b.clean()
|
||||
|
||||
# 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):
|
||||
b.clean()
|
||||
|
||||
# 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()
|
||||
|
||||
def test_duplicate_bom_line(self):
|
||||
@ -302,13 +304,24 @@ class BuildTest(BuildTestBase):
|
||||
allocations: Map of {StockItem: quantity}
|
||||
"""
|
||||
|
||||
items_to_create = []
|
||||
|
||||
for item, quantity in allocations.items():
|
||||
BuildItem.objects.create(
|
||||
|
||||
# Find an appropriate BuildLine to allocate against
|
||||
line = BuildLine.objects.filter(
|
||||
build=self.build,
|
||||
bom_item__sub_part=item.part
|
||||
).first()
|
||||
|
||||
items_to_create.append(BuildItem(
|
||||
build_line=line,
|
||||
stock_item=item,
|
||||
quantity=quantity,
|
||||
install_into=output
|
||||
)
|
||||
))
|
||||
|
||||
BuildItem.objects.bulk_create(items_to_create)
|
||||
|
||||
def test_partial_allocation(self):
|
||||
"""Test partial allocation of stock"""
|
||||
@ -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
|
||||
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
|
||||
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(
|
||||
None,
|
||||
@ -357,17 +371,17 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
self.assertFalse(self.build.is_fully_allocated(None))
|
||||
|
||||
unallocated = self.build.unallocated_bom_items(None)
|
||||
|
||||
self.assertEqual(len(unallocated), 1)
|
||||
|
||||
self.build.unallocateStock()
|
||||
|
||||
unallocated = self.build.unallocated_bom_items(None)
|
||||
unallocated = self.build.unallocated_lines()
|
||||
|
||||
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.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):
|
||||
"""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.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_2, None)
|
||||
@ -587,7 +601,7 @@ class BuildTest(BuildTestBase):
|
||||
"""Unit tests for the metadata field."""
|
||||
|
||||
# Make sure a BuildItem exists before trying to run this test
|
||||
b = BuildItem(stock_item=self.stock_1_2, build=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()
|
||||
|
||||
for model in [Build, BuildItem]:
|
||||
@ -644,7 +658,7 @@ class AutoAllocationTests(BuildTestBase):
|
||||
# No build item allocations have been made against the build
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
|
||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
# Stock is not interchangeable, nothing will happen
|
||||
self.build.auto_allocate_stock(
|
||||
@ -652,15 +666,15 @@ class AutoAllocationTests(BuildTestBase):
|
||||
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.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||
self.assertFalse(self.line_1.is_fully_allocated())
|
||||
self.assertFalse(self.line_2.is_fully_allocated())
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30)
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 50)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 30)
|
||||
|
||||
# This time we expect stock to be allocated!
|
||||
self.build.auto_allocate_stock(
|
||||
@ -669,15 +683,15 @@ class AutoAllocationTests(BuildTestBase):
|
||||
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.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||
self.assertTrue(self.line_1.is_fully_allocated())
|
||||
self.assertFalse(self.line_2.is_fully_allocated())
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5)
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 5)
|
||||
|
||||
# This time, allow substitute parts to be used!
|
||||
self.build.auto_allocate_stock(
|
||||
@ -685,12 +699,11 @@ class AutoAllocationTests(BuildTestBase):
|
||||
substitutes=True,
|
||||
)
|
||||
|
||||
# self.assertEqual(self.build.allocated_stock.count(), 8)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5.0)
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 5)
|
||||
|
||||
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||
self.assertTrue(self.line_1.is_fully_allocated())
|
||||
self.assertFalse(self.line_2.is_fully_allocated())
|
||||
|
||||
def test_fully_auto(self):
|
||||
"""We should be able to auto-allocate against a build in a single go"""
|
||||
@ -701,7 +714,7 @@ class AutoAllocationTests(BuildTestBase):
|
||||
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.build.unallocated_quantity(self.bom_item_2), 0)
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
@ -19,14 +19,7 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
name='Widget',
|
||||
description='Buildable Part',
|
||||
active=True,
|
||||
)
|
||||
|
||||
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
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
@ -34,7 +27,7 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
Build.objects.create(
|
||||
part=buildable_part,
|
||||
title='A build of some stuff',
|
||||
quantity=50
|
||||
quantity=50,
|
||||
)
|
||||
|
||||
def test_items_exist(self):
|
||||
@ -67,7 +60,8 @@ class TestReferenceMigration(MigratorTestCase):
|
||||
|
||||
part = Part.objects.create(
|
||||
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')
|
||||
@ -158,3 +152,139 @@ class TestReferencePatternMigration(MigratorTestCase):
|
||||
pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN')
|
||||
|
||||
self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}')
|
||||
|
||||
|
||||
class TestBuildLineCreation(MigratorTestCase):
|
||||
"""Test that build lines are correctly created for existing builds.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/pull/4855
|
||||
|
||||
This PR added the 'BuildLine' model, which acts as a link between a Build and a BomItem.
|
||||
|
||||
- Migration 0044 creates BuildLine objects for existing builds.
|
||||
- Migration 0046 links any existing BuildItem objects to corresponding BuildLine
|
||||
"""
|
||||
|
||||
migrate_from = ('build', '0041_alter_build_title')
|
||||
migrate_to = ('build', '0047_auto_20230606_1058')
|
||||
|
||||
def prepare(self):
|
||||
"""Create data to work with"""
|
||||
|
||||
# Model references
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
BomItem = self.old_state.apps.get_model('part', 'bomitem')
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
BuildItem = self.old_state.apps.get_model('build', 'builditem')
|
||||
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
|
||||
|
||||
# The "BuildLine" model does not exist yet
|
||||
with self.assertRaises(LookupError):
|
||||
self.old_state.apps.get_model('build', 'buildline')
|
||||
|
||||
# Create a part
|
||||
assembly = Part.objects.create(
|
||||
name='Assembly',
|
||||
description='An assembly',
|
||||
assembly=True,
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
# Create components
|
||||
for idx in range(1, 11):
|
||||
part = Part.objects.create(
|
||||
name=f"Part {idx}",
|
||||
description=f"Part {idx}",
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
# Create plentiful stock
|
||||
StockItem.objects.create(
|
||||
part=part,
|
||||
quantity=1000,
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
# Create a BOM item
|
||||
BomItem.objects.create(
|
||||
part=assembly,
|
||||
sub_part=part,
|
||||
quantity=idx,
|
||||
reference=f"REF-{idx}",
|
||||
)
|
||||
|
||||
# Create some builds
|
||||
for idx in range(1, 4):
|
||||
build = Build.objects.create(
|
||||
part=assembly,
|
||||
title=f"Build {idx}",
|
||||
quantity=idx * 10,
|
||||
reference=f"REF-{idx}",
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
# Allocate stock to the build
|
||||
for bom_item in BomItem.objects.all():
|
||||
stock_item = StockItem.objects.get(part=bom_item.sub_part)
|
||||
BuildItem.objects.create(
|
||||
build=build,
|
||||
bom_item=bom_item,
|
||||
stock_item=stock_item,
|
||||
quantity=bom_item.quantity,
|
||||
)
|
||||
|
||||
def test_build_line_creation(self):
|
||||
"""Test that the BuildLine objects have been created correctly"""
|
||||
|
||||
Build = self.new_state.apps.get_model('build', 'build')
|
||||
BomItem = self.new_state.apps.get_model('part', 'bomitem')
|
||||
BuildLine = self.new_state.apps.get_model('build', 'buildline')
|
||||
BuildItem = self.new_state.apps.get_model('build', 'builditem')
|
||||
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
|
||||
|
||||
# There should be 3x builds
|
||||
self.assertEqual(Build.objects.count(), 3)
|
||||
|
||||
# 10x BOMItem objects
|
||||
self.assertEqual(BomItem.objects.count(), 10)
|
||||
|
||||
# 10x StockItem objects
|
||||
self.assertEqual(StockItem.objects.count(), 10)
|
||||
|
||||
# And 30x BuildLine items (1 for each BomItem for each Build)
|
||||
self.assertEqual(BuildLine.objects.count(), 30)
|
||||
|
||||
# And 30x BuildItem objects (1 for each BomItem for each Build)
|
||||
self.assertEqual(BuildItem.objects.count(), 30)
|
||||
|
||||
# Check that each BuildItem has been linked to a BuildLine
|
||||
for item in BuildItem.objects.all():
|
||||
self.assertIsNotNone(item.build_line)
|
||||
self.assertEqual(
|
||||
item.stock_item.part,
|
||||
item.build_line.bom_item.sub_part,
|
||||
)
|
||||
|
||||
item = BuildItem.objects.first()
|
||||
|
||||
# Check that the "build" field has been removed
|
||||
with self.assertRaises(AttributeError):
|
||||
item.build
|
||||
|
||||
# Check that the "bom_item" field has been removed
|
||||
with self.assertRaises(AttributeError):
|
||||
item.bom_item
|
||||
|
||||
# Check that each BuildLine is correctly configured
|
||||
for line in BuildLine.objects.all():
|
||||
# Check that the quantity is correct
|
||||
self.assertEqual(
|
||||
line.quantity,
|
||||
line.build.quantity * line.bom_item.quantity,
|
||||
)
|
||||
|
||||
# Check that the linked parts are correct
|
||||
self.assertEqual(
|
||||
line.build.part,
|
||||
line.bom_item.part,
|
||||
)
|
||||
|
@ -39,7 +39,5 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
part = build.part
|
||||
|
||||
ctx['part'] = part
|
||||
ctx['has_tracked_bom_items'] = build.has_tracked_bom_items()
|
||||
ctx['has_untracked_bom_items'] = build.has_untracked_bom_items()
|
||||
|
||||
return ctx
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.http.response import HttpResponse
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.decorators import method_decorator
|
||||
@ -121,8 +122,13 @@ class CurrencyExchangeView(APIView):
|
||||
|
||||
# Information on last update
|
||||
try:
|
||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
|
||||
|
||||
if backend.exists():
|
||||
backend = backend.first()
|
||||
updated = backend.last_update
|
||||
else:
|
||||
updated = None
|
||||
except Exception:
|
||||
updated = None
|
||||
|
||||
@ -480,6 +486,29 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
|
||||
|
||||
class FlagList(ListAPI):
|
||||
"""List view for feature flags."""
|
||||
|
||||
queryset = settings.FLAGS
|
||||
serializer_class = common.serializers.FlagSerializer
|
||||
permission_classes = [permissions.AllowAny, ]
|
||||
|
||||
|
||||
class FlagDetail(RetrieveAPI):
|
||||
"""Detail view for an individual feature flag."""
|
||||
|
||||
serializer_class = common.serializers.FlagSerializer
|
||||
permission_classes = [permissions.AllowAny, ]
|
||||
|
||||
def get_object(self):
|
||||
"""Attempt to find a config object with the provided key."""
|
||||
key = self.kwargs['key']
|
||||
value = settings.FLAGS.get(key, None)
|
||||
if not value:
|
||||
raise NotFound()
|
||||
return {key: value}
|
||||
|
||||
|
||||
settings_api_urls = [
|
||||
# User settings
|
||||
re_path(r'^user/', include([
|
||||
@ -496,7 +525,7 @@ settings_api_urls = [
|
||||
path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
|
||||
|
||||
# Notification Settings List
|
||||
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
|
||||
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notification-setting-list'),
|
||||
])),
|
||||
|
||||
# Global settings
|
||||
@ -552,6 +581,11 @@ common_api_urls = [
|
||||
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),
|
||||
])),
|
||||
|
||||
# Flags
|
||||
path('flags/', include([
|
||||
path('<str:key>/', FlagDetail.as_view(), name='api-flag-detail'),
|
||||
re_path(r'^.*$', FlagList.as_view(), name='api-flag-list'),
|
||||
])),
|
||||
]
|
||||
|
||||
admin_api_urls = [
|
||||
|
@ -4,15 +4,40 @@ import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class CreateModelOrSkip(migrations.CreateModel):
|
||||
"""Custom migration operation to create a model if it does not already exist.
|
||||
|
||||
- If the model already exists, the migration is skipped
|
||||
- This class has been added to deal with some errors being thrown in CI tests
|
||||
- The 'common_currency' table doesn't exist anymore anyway!
|
||||
- In the future, these migrations will be squashed
|
||||
"""
|
||||
|
||||
def database_forwards(self, app_label, schema_editor, from_state, to_state) -> None:
|
||||
"""Forwards migration *attempts* to create the model, but will fail gracefully if it already exists"""
|
||||
|
||||
try:
|
||||
super().database_forwards(app_label, schema_editor, from_state, to_state)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def state_forwards(self, app_label, state) -> None:
|
||||
try:
|
||||
super().state_forwards(app_label, state)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
CreateModelOrSkip(
|
||||
name='Currency',
|
||||
fields=[
|
||||
('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.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)
|
||||
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)
|
||||
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)
|
||||
"""
|
||||
|
||||
@ -140,6 +141,7 @@ class SettingsKeyType(TypedDict, total=False):
|
||||
before_save: Callable[..., None]
|
||||
after_save: Callable[..., None]
|
||||
protected: bool
|
||||
required: bool
|
||||
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)}
|
||||
|
||||
@classmethod
|
||||
def allValues(cls, exclude_hidden=False, **kwargs):
|
||||
"""Return a dict of "all" defined global settings.
|
||||
def all_settings(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
|
||||
"""Return a list of "all" defined settings.
|
||||
|
||||
This performs a single database lookup,
|
||||
and then any settings which are not *in* the database
|
||||
are assigned their default values
|
||||
"""
|
||||
filters = cls.get_filters(**kwargs)
|
||||
|
||||
results = cls.objects.all()
|
||||
|
||||
if exclude_hidden:
|
||||
@ -264,45 +268,83 @@ class BaseInvenTreeSetting(models.Model):
|
||||
results = results.exclude(key__startswith='_')
|
||||
|
||||
# Optionally filter by other keys
|
||||
results = results.filter(**cls.get_filters(**kwargs))
|
||||
results = results.filter(**filters)
|
||||
|
||||
settings: Dict[str, BaseInvenTreeSetting] = {}
|
||||
|
||||
# Query the database
|
||||
settings = {}
|
||||
|
||||
for setting in results:
|
||||
if setting.key:
|
||||
settings[setting.key.upper()] = setting.value
|
||||
settings[setting.key.upper()] = setting
|
||||
|
||||
# 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:
|
||||
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:
|
||||
hidden = cls.SETTINGS[key].get('hidden', False)
|
||||
|
||||
if hidden:
|
||||
# Remove hidden items
|
||||
# remove any hidden settings
|
||||
if exclude_hidden and setting.get("hidden", False):
|
||||
del settings[key.upper()]
|
||||
|
||||
for key, value in settings.items():
|
||||
validator = cls.get_setting_validator(key)
|
||||
# format settings values and remove protected
|
||||
for key, setting in settings.items():
|
||||
validator = cls.get_setting_validator(key, **filters)
|
||||
|
||||
if cls.is_protected(key):
|
||||
value = '***'
|
||||
if cls.is_protected(key, **filters) and setting.value != "":
|
||||
setting.value = '***'
|
||||
elif cls.validator_is_bool(validator):
|
||||
value = InvenTree.helpers.str2bool(value)
|
||||
setting.value = InvenTree.helpers.str2bool(setting.value)
|
||||
elif cls.validator_is_int(validator):
|
||||
try:
|
||||
value = int(value)
|
||||
setting.value = int(setting.value)
|
||||
except ValueError:
|
||||
value = cls.get_setting_default(key)
|
||||
|
||||
settings[key] = value
|
||||
setting.value = cls.get_setting_default(key, **filters)
|
||||
|
||||
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
|
||||
def get_setting_definition(cls, key, **kwargs):
|
||||
"""Return the 'definition' of a particular settings value, as a dict object.
|
||||
@ -829,7 +871,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
@classmethod
|
||||
def is_protected(cls, key, **kwargs):
|
||||
"""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)
|
||||
|
||||
@ -838,6 +880,18 @@ class BaseInvenTreeSetting(models.Model):
|
||||
"""Returns if setting is protected from rendering."""
|
||||
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():
|
||||
"""Build up group tuple for settings based on your choices."""
|
||||
@ -1249,6 +1303,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Minimum Pricing Decimal Places'),
|
||||
'description': _('Minimum number of decimal places to display when rendering pricing data'),
|
||||
@ -1467,6 +1528,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Build Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Build Order reference field'),
|
||||
@ -1616,13 +1684,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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
|
||||
'ENABLE_PLUGINS_URL': {
|
||||
'name': _('Enable URL integration'),
|
||||
@ -1678,6 +1739,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Automatic Stocktake Period'),
|
||||
'description': _('Number of days between automatic stocktake recording (set to zero to disable)'),
|
||||
@ -1775,13 +1843,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Show unvalidated BOMs'),
|
||||
'description': _('Show BOMs that await validation on the homepage'),
|
||||
@ -1796,13 +1857,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Show low stock'),
|
||||
'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.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.models
|
||||
import InvenTree.helpers
|
||||
from common.models import NotificationEntry, NotificationMessage
|
||||
from InvenTree.ready import isImportingData
|
||||
from plugin import registry
|
||||
from plugin.models import NotificationUserSetting, PluginConfig
|
||||
@ -247,7 +247,7 @@ class UIMessageNotification(SingleNotificationMethod):
|
||||
|
||||
def send(self, target):
|
||||
"""Send a UI notification to a user."""
|
||||
NotificationMessage.objects.create(
|
||||
common.models.NotificationMessage.objects.create(
|
||||
target_object=self.obj,
|
||||
source_object=target,
|
||||
user=target,
|
||||
@ -279,7 +279,7 @@ class NotificationBody:
|
||||
name: str
|
||||
slug: str
|
||||
message: str
|
||||
template: str
|
||||
template: str = None
|
||||
|
||||
|
||||
class InvenTreeNotificationBodies:
|
||||
@ -338,7 +338,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
# Check if we have notified recently...
|
||||
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")
|
||||
return
|
||||
|
||||
@ -398,7 +398,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
logger.error(error)
|
||||
|
||||
# Set delivery flag
|
||||
NotificationEntry.notify(category, obj_ref_value)
|
||||
common.models.NotificationEntry.notify(category, obj_ref_value)
|
||||
else:
|
||||
logger.info(f"No possible users for notification '{category}'")
|
||||
|
||||
|
@ -1,18 +1,37 @@
|
||||
"""JSON serializers for common components."""
|
||||
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from flags.state import flag_state
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.models import (InvenTreeSetting, InvenTreeUserSetting,
|
||||
NewsFeedEntry, NotesImage, NotificationMessage,
|
||||
ProjectCode)
|
||||
import common.models as common_models
|
||||
from InvenTree.helpers import get_objectreference
|
||||
from InvenTree.helpers_model import construct_absolute_url
|
||||
from InvenTree.serializers import (InvenTreeImageSerializerField,
|
||||
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):
|
||||
"""Base serializer for a settings object."""
|
||||
|
||||
@ -30,6 +49,8 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
||||
|
||||
api_url = serializers.CharField(read_only=True)
|
||||
|
||||
value = SettingsValueField()
|
||||
|
||||
def get_choices(self, obj):
|
||||
"""Returns the choices available for a given item."""
|
||||
results = []
|
||||
@ -45,16 +66,6 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
||||
|
||||
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):
|
||||
"""Serializer for the InvenTreeSetting model."""
|
||||
@ -62,7 +73,7 @@ class GlobalSettingsSerializer(SettingsSerializer):
|
||||
class Meta:
|
||||
"""Meta options for GlobalSettingsSerializer."""
|
||||
|
||||
model = InvenTreeSetting
|
||||
model = common_models.InvenTreeSetting
|
||||
fields = [
|
||||
'pk',
|
||||
'key',
|
||||
@ -83,7 +94,7 @@ class UserSettingsSerializer(SettingsSerializer):
|
||||
class Meta:
|
||||
"""Meta options for UserSettingsSerializer."""
|
||||
|
||||
model = InvenTreeUserSetting
|
||||
model = common_models.InvenTreeUserSetting
|
||||
fields = [
|
||||
'pk',
|
||||
'key',
|
||||
@ -146,7 +157,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
|
||||
class Meta:
|
||||
"""Meta options for NotificationMessageSerializer."""
|
||||
|
||||
model = NotificationMessage
|
||||
model = common_models.NotificationMessage
|
||||
fields = [
|
||||
'pk',
|
||||
'target',
|
||||
@ -207,7 +218,7 @@ class NewsFeedEntrySerializer(InvenTreeModelSerializer):
|
||||
class Meta:
|
||||
"""Meta options for NewsFeedEntrySerializer."""
|
||||
|
||||
model = NewsFeedEntry
|
||||
model = common_models.NewsFeedEntry
|
||||
fields = [
|
||||
'pk',
|
||||
'feed_id',
|
||||
@ -241,7 +252,7 @@ class NotesImageSerializer(InvenTreeModelSerializer):
|
||||
class Meta:
|
||||
"""Meta options for NotesImageSerializer."""
|
||||
|
||||
model = NotesImage
|
||||
model = common_models.NotesImage
|
||||
fields = [
|
||||
'pk',
|
||||
'image',
|
||||
@ -263,9 +274,25 @@ class ProjectCodeSerializer(InvenTreeModelSerializer):
|
||||
class Meta:
|
||||
"""Meta options for ProjectCodeSerializer."""
|
||||
|
||||
model = ProjectCode
|
||||
model = common_models.ProjectCode
|
||||
fields = [
|
||||
'pk',
|
||||
'code',
|
||||
'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."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def currency_code_default():
|
||||
"""Returns the default currency code (or USD if not specified)"""
|
||||
from django.db.utils import ProgrammingError
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
try:
|
||||
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', create=False, cache=False)
|
||||
except ProgrammingError: # pragma: no cover
|
||||
# database is not initialized yet
|
||||
except Exception: # pragma: no cover
|
||||
# Database may not yet be ready, no need to throw an error here
|
||||
code = ''
|
||||
|
||||
if code not in CURRENCIES:
|
||||
@ -42,4 +45,4 @@ def stock_expiry_enabled():
|
||||
"""Returns True if the stock expiry feature is enabled."""
|
||||
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
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
@ -105,6 +106,40 @@ class SettingsTest(InvenTreeTestCase):
|
||||
self.assertIn('PART_COPY_TESTS', result)
|
||||
self.assertIn('STOCK_OWNERSHIP_CONTROL', 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):
|
||||
"""Test that all settings are valid.
|
||||
@ -226,7 +261,7 @@ class SettingsTest(InvenTreeTestCase):
|
||||
|
||||
cache.clear()
|
||||
|
||||
# Generate a number of new usesr
|
||||
# Generate a number of new users
|
||||
for idx in range(5):
|
||||
get_user_model().objects.create(
|
||||
username=f"User_{idx}",
|
||||
@ -417,7 +452,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertTrue(str2bool(response.data['value']))
|
||||
|
||||
# Assign some falsey values
|
||||
# Assign some false(ish) values
|
||||
for v in ['false', False, '0', 'n', 'FalSe']:
|
||||
self.patch(
|
||||
url,
|
||||
@ -535,7 +570,7 @@ class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
|
||||
|
||||
def test_api_list(self):
|
||||
"""Test list URL."""
|
||||
url = reverse('api-notifcation-setting-list')
|
||||
url = reverse('api-notification-setting-list')
|
||||
|
||||
self.get(url, expected_code=200)
|
||||
|
||||
@ -583,7 +618,7 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
|
||||
|
||||
# Failure mode tests
|
||||
|
||||
# Non - exsistant plugin
|
||||
# Non-existent plugin
|
||||
url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'})
|
||||
response = self.get(url, expected_code=404)
|
||||
self.assertIn("Plugin 'doesnotexist' not installed", str(response.data))
|
||||
@ -729,7 +764,7 @@ class WebhookMessageTests(TestCase):
|
||||
|
||||
|
||||
class NotificationTest(InvenTreeAPITestCase):
|
||||
"""Tests for NotificationEntriy."""
|
||||
"""Tests for NotificationEntry."""
|
||||
|
||||
fixtures = [
|
||||
'users',
|
||||
@ -875,6 +910,43 @@ class CommonTest(InvenTreeAPITestCase):
|
||||
self.user.is_superuser = False
|
||||
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):
|
||||
"""Tests for ColorTheme."""
|
||||
|
@ -9,9 +9,9 @@ from import_export.fields import Field
|
||||
from InvenTree.admin import InvenTreeResource
|
||||
from part.models import Part
|
||||
|
||||
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter, SupplierPart,
|
||||
SupplierPriceBreak)
|
||||
from .models import (Address, Company, Contact, ManufacturerPart,
|
||||
ManufacturerPartAttachment, ManufacturerPartParameter,
|
||||
SupplierPart, SupplierPriceBreak)
|
||||
|
||||
|
||||
class CompanyResource(InvenTreeResource):
|
||||
@ -187,6 +187,60 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
|
||||
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(SupplierPart, SupplierPartAdmin)
|
||||
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
||||
@ -194,3 +248,6 @@ admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
||||
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
||||
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
|
||||
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.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
|
||||
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
|
||||
ManufacturerPartAttachment, ManufacturerPartParameter,
|
||||
SupplierPart, SupplierPriceBreak)
|
||||
from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
|
||||
ContactSerializer,
|
||||
from .models import (Address, Company, CompanyAttachment, Contact,
|
||||
ManufacturerPart, ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter, SupplierPart,
|
||||
SupplierPriceBreak)
|
||||
from .serializers import (AddressSerializer, CompanyAttachmentSerializer,
|
||||
CompanySerializer, ContactSerializer,
|
||||
ManufacturerPartAttachmentSerializer,
|
||||
ManufacturerPartParameterSerializer,
|
||||
ManufacturerPartSerializer, SupplierPartSerializer,
|
||||
@ -135,6 +136,32 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
|
||||
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):
|
||||
"""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'^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'),
|
||||
|
||||
]
|
||||
|
@ -8,6 +8,7 @@ import common.settings
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0004_inventreesetting'),
|
||||
('company', '0024_unique_name_email_constraint'),
|
||||
]
|
||||
|
||||
|
@ -8,6 +8,7 @@ import djmoney.models.fields
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0004_inventreesetting'),
|
||||
('company', '0038_manufacturerpartparameter'),
|
||||
]
|
||||
|
||||
|
@ -8,6 +8,7 @@ import djmoney.models.validators
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0004_inventreesetting'),
|
||||
('company', '0050_alter_company_website'),
|
||||
]
|
||||
|
||||
|
@ -8,6 +8,7 @@ import InvenTree.fields
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0094_auto_20230220_0025'),
|
||||
('company', '0058_auto_20230515_0004'),
|
||||
]
|
||||
|
||||
|
33
InvenTree/company/migrations/0063_auto_20230502_1956.py
Normal file
33
InvenTree/company/migrations/0063_auto_20230502_1956.py
Normal file
@ -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)
|
||||
]
|
17
InvenTree/company/migrations/0065_remove_company_address.py
Normal file
17
InvenTree/company/migrations/0065_remove_company_address.py
Normal file
@ -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',
|
||||
),
|
||||
]
|
22
InvenTree/company/migrations/0066_auto_20230616_2059.py
Normal file
22
InvenTree/company/migrations/0066_auto_20230616_2059.py
Normal file
@ -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.db import models
|
||||
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.urls import reverse
|
||||
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.models import (InvenTreeAttachment, InvenTreeBarcodeMixin,
|
||||
InvenTreeNotesMixin, MetadataMixin)
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
from InvenTree.status_codes import PurchaseOrderStatusGroups
|
||||
|
||||
|
||||
def rename_company_image(instance, filename):
|
||||
@ -72,7 +72,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
|
||||
name: Brief name of the company
|
||||
description: Longer form description
|
||||
website: URL for the company website
|
||||
address: Postal address
|
||||
address: One-line string representation of primary address
|
||||
phone: contact phone number
|
||||
email: contact email address
|
||||
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')
|
||||
)
|
||||
|
||||
address = models.CharField(max_length=200,
|
||||
verbose_name=_('Address'),
|
||||
blank=True, help_text=_('Company address'))
|
||||
|
||||
phone = models.CharField(max_length=50,
|
||||
verbose_name=_('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],
|
||||
)
|
||||
|
||||
@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
|
||||
def currency_code(self):
|
||||
"""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)
|
||||
|
||||
|
||||
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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""Return the total quantity of items currently on order.
|
||||
|
@ -20,9 +20,10 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||
RemoteImageMixin)
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
|
||||
ManufacturerPartAttachment, ManufacturerPartParameter,
|
||||
SupplierPart, SupplierPriceBreak)
|
||||
from .models import (Address, Company, CompanyAttachment, Contact,
|
||||
ManufacturerPart, ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter, SupplierPart,
|
||||
SupplierPriceBreak)
|
||||
|
||||
|
||||
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
@ -45,6 +46,53 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
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):
|
||||
"""Serializer for Company object (full detail)"""
|
||||
|
||||
@ -73,11 +121,13 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
'parts_supplied',
|
||||
'parts_manufactured',
|
||||
'remote_image',
|
||||
'address_count',
|
||||
'primary_address'
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Annoate the supplied queryset with aggregated information"""
|
||||
"""Annotate the supplied queryset with aggregated information"""
|
||||
# Add count of parts manufactured
|
||||
queryset = queryset.annotate(
|
||||
parts_manufactured=SubqueryCount('manufactured_parts')
|
||||
@ -87,14 +137,21 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
parts_supplied=SubqueryCount('supplied_parts')
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
address_count=SubqueryCount('addresses')
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
primary_address = AddressSerializer(required=False, allow_null=True, read_only=True)
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
image = InvenTreeImageSerializerField(required=False, allow_null=True)
|
||||
|
||||
parts_supplied = 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)
|
||||
|
||||
@ -334,7 +391,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
|
||||
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)
|
||||
|
||||
|
@ -26,29 +26,8 @@
|
||||
<div class='panel-content'>
|
||||
{% if roles.purchase_order.change %}
|
||||
<div id='supplier-part-button-toolbar'>
|
||||
<div class='button-toolbar container-fluid'>
|
||||
<div class='btn-group' role='group'>
|
||||
<div class='btn-group'>
|
||||
<button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-bs-toggle="dropdown">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<table class='table table-striped table-condensed' id='supplier-part-table' data-toolbar='#supplier-part-button-toolbar'>
|
||||
@ -73,30 +52,8 @@
|
||||
<div class='panel-content'>
|
||||
{% if roles.purchase_order.change %}
|
||||
<div id='manufacturer-part-button-toolbar'>
|
||||
<div class='button-toolbar container-fluid'>
|
||||
<div class='btn-group' role='group'>
|
||||
<div class='btn-group' role='group'>
|
||||
<button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-bs-toggle="dropdown">
|
||||
<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>
|
||||
{% endif %}
|
||||
<table class='table table-striped table-condensed' id='manufacturer-part-table' data-toolbar='#manufacturer-part-button-toolbar'>
|
||||
</table>
|
||||
@ -128,10 +85,8 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='po-button-bar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
{% include "filter_list.html" with id="purchaseorder" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#po-button-bar'>
|
||||
</table>
|
||||
@ -156,10 +111,8 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='so-button-bar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
{% include "filter_list.html" with id="salesorder" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed po-table' id='sales-order-table' data-toolbar='#so-button-bar'>
|
||||
</table>
|
||||
@ -174,10 +127,8 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='assigned-stock-button-toolbar'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="customerstock" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
|
||||
</div>
|
||||
@ -201,12 +152,8 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='table-buttons'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
{% include "filter_list.html" with id="returnorder" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' data-toolbar='#table-buttons' id='return-order-table'>
|
||||
</table>
|
||||
</div>
|
||||
@ -246,15 +193,36 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='contacts-button-toolbar'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="contacts" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='contacts-table' data-toolbar='#contacts-button-toolbar'></table>
|
||||
</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-heading'>
|
||||
<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
|
||||
onPanelLoad('company-notes', function() {
|
||||
|
||||
@ -331,9 +319,6 @@
|
||||
supplier_part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
],
|
||||
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 %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
enableSidebar('company');
|
||||
|
@ -24,11 +24,11 @@
|
||||
|
||||
<div class='panel-content'>
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div id='company-button-toolbar'>
|
||||
{% include "filter_list.html" with id='company' %}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
@ -127,18 +127,8 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='supplier-button-toolbar'>
|
||||
<div class='btn-group'>
|
||||
<div id='opt-dropdown' class="btn-group">
|
||||
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<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>
|
||||
|
||||
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#supplier-button-toolbar'>
|
||||
</table>
|
||||
@ -174,18 +164,8 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='parameter-toolbar'>
|
||||
<div class='btn-group'>
|
||||
<div id='opt-dropdown' class="btn-group">
|
||||
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<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>
|
||||
|
||||
<table class='table table-striped table-condensed' id='parameter-table' data-toolbar='#parameter-toolbar'></table>
|
||||
</div>
|
||||
@ -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(
|
||||
"#supplier-table",
|
||||
"{% url 'api-supplier-part-list' %}",
|
||||
|
@ -32,6 +32,8 @@
|
||||
{% endif %}
|
||||
{% trans "Contacts" as text %}
|
||||
{% 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 %}
|
||||
{% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %}
|
||||
{% trans "Attachments" as text %}
|
||||
|
@ -234,10 +234,8 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='button-bar'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id='purchaseorder' %}
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#button-bar'>
|
||||
</table>
|
||||
</div>
|
||||
@ -258,11 +256,9 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='price-break-toolbar' class='btn-group'>
|
||||
<div class='btn-group' role='group'>
|
||||
<div id='price-break-toolbar'>
|
||||
{% include "filter_list.html" with id='supplierpricebreak' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>
|
||||
</table>
|
||||
@ -326,7 +322,6 @@ loadStockTable($("#stock-table"), {
|
||||
location_detail: true,
|
||||
part_detail: false,
|
||||
},
|
||||
buttons: ['#stock-options'],
|
||||
});
|
||||
|
||||
$("#item-create").click(function() {
|
||||
|
@ -6,7 +6,7 @@ from rest_framework import status
|
||||
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
|
||||
from .models import Company, Contact, ManufacturerPart, SupplierPart
|
||||
from .models import Address, Company, Contact, ManufacturerPart, SupplierPart
|
||||
|
||||
|
||||
class CompanyTest(InvenTreeAPITestCase):
|
||||
@ -284,6 +284,138 @@ class ContactTest(InvenTreeAPITestCase):
|
||||
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):
|
||||
"""Series of tests for the Manufacturer DRF API."""
|
||||
|
||||
@ -468,7 +600,7 @@ class SupplierPartTest(InvenTreeAPITestCase):
|
||||
self.assertIsNone(sp.availability_updated)
|
||||
self.assertEqual(sp.available, 0)
|
||||
|
||||
# Now, *update* the availabile quantity via the API
|
||||
# Now, *update* the available quantity via the API
|
||||
self.patch(
|
||||
reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}),
|
||||
{
|
||||
|
@ -48,7 +48,8 @@ class TestManufacturerField(MigratorTestCase):
|
||||
# Create an initial part
|
||||
part = Part.objects.create(
|
||||
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
|
||||
@ -279,6 +280,47 @@ class TestCurrencyMigration(MigratorTestCase):
|
||||
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):
|
||||
"""Test that the supplier part quantity is correctly migrated."""
|
||||
|
||||
|
@ -4,11 +4,12 @@ import os
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.test import TestCase
|
||||
|
||||
from part.models import Part
|
||||
|
||||
from .models import (Company, Contact, ManufacturerPart, SupplierPart,
|
||||
from .models import (Address, Company, Contact, ManufacturerPart, SupplierPart,
|
||||
rename_company_image)
|
||||
|
||||
|
||||
@ -35,7 +36,6 @@ class CompanySimpleTest(TestCase):
|
||||
Company.objects.create(name='ABC Co.',
|
||||
description='Seller of ABC products',
|
||||
website='www.abc-sales.com',
|
||||
address='123 Sales St.',
|
||||
is_customer=False,
|
||||
is_supplier=True)
|
||||
|
||||
@ -174,6 +174,88 @@ class ContactSimpleTest(TestCase):
|
||||
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):
|
||||
"""Unit tests for the ManufacturerPart model"""
|
||||
|
||||
|
@ -183,6 +183,11 @@ login_default_protocol: http
|
||||
remote_login_enabled: False
|
||||
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
|
||||
# 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
|
||||
@ -228,3 +233,11 @@ remote_login_header: HTTP_REMOTE_USER
|
||||
# splash: splash_screen.jpg
|
||||
# hide_admin_link: 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'
|
||||
|
4
InvenTree/generic/__init__.py
Normal file
4
InvenTree/generic/__init__.py
Normal file
@ -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.
|
||||
"""
|
13
InvenTree/generic/states/__init__.py
Normal file
13
InvenTree/generic/states/__init__.py
Normal file
@ -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,
|
||||
]
|
54
InvenTree/generic/states/api.py
Normal file
54
InvenTree/generic/states/api.py
Normal file
@ -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)
|
170
InvenTree/generic/states/states.py
Normal file
170
InvenTree/generic/states/states.py
Normal file
@ -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
|
17
InvenTree/generic/states/tags.py
Normal file
17
InvenTree/generic/states/tags.py
Normal file
@ -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}'")
|
110
InvenTree/generic/states/tests.py
Normal file
110
InvenTree/generic/states/tests.py
Normal file
@ -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")
|
1
InvenTree/generic/templatetags/__init__.py
Normal file
1
InvenTree/generic/templatetags/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Template tags for generic *things*."""
|
10
InvenTree/generic/templatetags/generic.py
Normal file
10
InvenTree/generic/templatetags/generic.py
Normal file
@ -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.StockLocationLabel, 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 rest_framework.exceptions import NotFound
|
||||
|
||||
import build.models
|
||||
import common.models
|
||||
import InvenTree.helpers
|
||||
import label.models
|
||||
@ -368,6 +369,31 @@ class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI):
|
||||
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 = [
|
||||
|
||||
# Stock item labels
|
||||
@ -408,4 +434,17 @@ label_api_urls = [
|
||||
# List view
|
||||
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():
|
||||
return
|
||||
|
||||
if canAppAccessDatabase():
|
||||
if canAppAccessDatabase(allow_test=False):
|
||||
|
||||
try:
|
||||
self.create_labels() # pragma: no cover
|
||||
@ -51,12 +51,12 @@ class LabelConfig(AppConfig):
|
||||
def create_labels(self):
|
||||
"""Create all default templates."""
|
||||
# Test if models are ready
|
||||
from .models import PartLabel, StockItemLabel, StockLocationLabel
|
||||
assert bool(StockLocationLabel is not None)
|
||||
import label.models
|
||||
assert bool(label.models.StockLocationLabel is not None)
|
||||
|
||||
# Create the categories
|
||||
self.create_labels_category(
|
||||
StockItemLabel,
|
||||
label.models.StockItemLabel,
|
||||
'stockitem',
|
||||
[
|
||||
{
|
||||
@ -70,7 +70,7 @@ class LabelConfig(AppConfig):
|
||||
)
|
||||
|
||||
self.create_labels_category(
|
||||
StockLocationLabel,
|
||||
label.models.StockLocationLabel,
|
||||
'stocklocation',
|
||||
[
|
||||
{
|
||||
@ -91,7 +91,7 @@ class LabelConfig(AppConfig):
|
||||
)
|
||||
|
||||
self.create_labels_category(
|
||||
PartLabel,
|
||||
label.models.PartLabel,
|
||||
'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):
|
||||
"""Create folder and database entries for the default templates, if they do not already exist."""
|
||||
# Create root dir for templates
|
||||
@ -173,6 +187,7 @@ class LabelConfig(AppConfig):
|
||||
|
||||
logger.info(f"Creating entry for {model} '{label['name']}'")
|
||||
|
||||
try:
|
||||
model.objects.create(
|
||||
name=label['name'],
|
||||
description=label['description'],
|
||||
@ -182,4 +197,5 @@ class LabelConfig(AppConfig):
|
||||
width=label['width'],
|
||||
height=label['height'],
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
logger.warning(f"Failed to create label '{label['name']}'")
|
||||
|
33
InvenTree/label/migrations/0010_buildlinelabel.py
Normal file
33
InvenTree/label/migrations/0010_buildlinelabel.py
Normal file
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
29
InvenTree/label/migrations/0011_auto_20230623_2158.py
Normal file
29
InvenTree/label/migrations/0011_auto_20230623_2158.py
Normal file
@ -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.utils.translation import gettext_lazy as _
|
||||
|
||||
import build.models
|
||||
import part.models
|
||||
import stock.models
|
||||
from InvenTree.helpers import normalize, validateFilterString
|
||||
@ -59,6 +60,13 @@ def validate_part_filters(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 for rendering a label to a PDF."""
|
||||
|
||||
@ -239,7 +247,7 @@ class StockItemLabel(LabelTemplate):
|
||||
|
||||
filters = models.CharField(
|
||||
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'),
|
||||
validators=[
|
||||
validate_stock_item_filters
|
||||
@ -280,7 +288,7 @@ class StockLocationLabel(LabelTemplate):
|
||||
|
||||
filters = models.CharField(
|
||||
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'),
|
||||
validators=[
|
||||
validate_stock_location_filters]
|
||||
@ -308,7 +316,7 @@ class PartLabel(LabelTemplate):
|
||||
|
||||
filters = models.CharField(
|
||||
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'),
|
||||
validators=[
|
||||
validate_part_filters
|
||||
@ -330,3 +338,38 @@ class PartLabel(LabelTemplate):
|
||||
'qr_url': request.build_absolute_uri(part.get_absolute_url()),
|
||||
'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
|
||||
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 %}
|
||||
|
||||
<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'>
|
||||
{{ part.full_name }}
|
||||
|
@ -18,5 +18,5 @@
|
||||
|
||||
{% 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 %}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user