2
0
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:
wolflu05
2023-07-12 11:27:15 +00:00
295 changed files with 97428 additions and 88416 deletions

1
.github/FUNDING.yml vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.'))

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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')},
},
),
]

View 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,
),
]

View 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'),
),
]

View 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,
)
]

View 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',
),
]

View 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'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ from InvenTree import status_codes as status
import common.models
import build.tasks
from build.models import Build, BuildItem, 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}'")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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')),
],
),
]

View File

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

View 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',
),
]

View 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'),
),
]

View File

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

View File

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

View File

@ -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');

View File

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

View File

@ -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' %}",

View File

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

View File

@ -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() {

View File

@ -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}),
{

View File

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

View File

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

View File

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

View 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.
"""

View 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,
]

View 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)

View 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

View 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}'")

View 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")

View File

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

View 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,
]

View File

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

View File

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

View File

@ -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']}'")

View 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,
},
),
]

View 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'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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