Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue4184

This commit is contained in:
Matthias Mair 2023-04-18 22:36:30 +02:00
commit 5ed6bd5db6
No known key found for this signature in database
GPG Key ID: A593429DDA23B66A
307 changed files with 161190 additions and 121905 deletions

71
.devops/testing_ci.yml Normal file
View File

@ -0,0 +1,71 @@
# Python Django
# Test a Django project on multiple versions of Python.
# Add steps that analyze code, save build artifacts, deploy, and more:
# https://docs.microsoft.com/azure/devops/pipelines/languages/python
trigger:
- master
pool:
vmImage: ubuntu-latest
strategy:
matrix:
Python39:
PYTHON_VERSION: '3.9'
maxParallel: 3
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '$(PYTHON_VERSION)'
architecture: 'x64'
- task: PythonScript@0
displayName: 'Export project path'
inputs:
scriptSource: 'inline'
script: |
"""Search all subdirectories for `manage.py`."""
from glob import iglob
from os import path
# Python >= 3.5
manage_py = next(iglob(path.join('**', 'manage.py'), recursive=True), None)
if not manage_py:
raise SystemExit('Could not find a Django project')
project_location = path.dirname(path.abspath(manage_py))
print('Found Django project in', project_location)
print('##vso[task.setvariable variable=projectRoot]{}'.format(project_location))
- script: |
python -m pip install --upgrade pip setuptools wheel
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install unittest-xml-reporting coverage invoke
sudo apt-get install poppler-utils
sudo apt-get install libpoppler-dev
displayName: 'Install prerequisites'
- script: |
pushd '$(projectRoot)'
invoke update
coverage run manage.py test --testrunner xmlrunner.extra.djangotestrunner.XMLTestRunner --no-input
coverage xml -i
displayName: 'Run tests'
env:
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: inventree
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
INVENTREE_BACKUP_DIR: ./backup
INVENTREE_PLUGINS_ENABLED: true
- task: PublishTestResults@2
inputs:
testResultsFiles: "**/TEST-*.xml"
testRunTitle: 'Python $(PYTHON_VERSION)'
condition: succeededOrFailed()
- task: PublishCodeCoverageResults@1
inputs:
codeCoverageTool: Cobertura
summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml'

View File

@ -6,7 +6,7 @@ body:
id: no-duplicate-issues id: no-duplicate-issues
attributes: attributes:
label: "Please verify that this bug has NOT been raised before." label: "Please verify that this bug has NOT been raised before."
description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=)" description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=) and read the [Frequently Asked Questions](https://docs.inventree.org/en/latest/faq/)!"
options: options:
- label: "I checked and didn't find a similar issue" - label: "I checked and didn't find a similar issue"
required: true required: true

View File

@ -48,7 +48,7 @@ runs:
if: ${{ inputs.python == 'true' }} if: ${{ inputs.python == 'true' }}
shell: bash shell: bash
run: | run: |
python3 -m pip install -U pip python3 -m pip install pip==23.0.1
pip3 install invoke wheel pip3 install invoke wheel
- name: Install Specific Python Dependencies - name: Install Specific Python Dependencies
if: ${{ inputs.pip-dependency }} if: ${{ inputs.pip-dependency }}

View File

@ -34,6 +34,8 @@ jobs:
cancel-in-progress: true cancel-in-progress: true
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
packages: write
id-token: write id-token: write
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -95,6 +97,15 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into registry ghcr.io
if: github.event_name != 'pull_request'
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a # pin@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata - name: Extract Docker metadata
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
id: meta id: meta
@ -102,6 +113,8 @@ jobs:
with: with:
images: | images: |
inventree/inventree inventree/inventree
ghcr.io/inventree/inventree
- name: Build and Push - name: Build and Push
id: build-and-push id: build-and-push
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@ -115,6 +128,7 @@ jobs:
build-args: | build-args: |
commit_hash=${{ env.git_commit_hash }} commit_hash=${{ env.git_commit_hash }}
commit_date=${{ env.git_commit_date }} commit_date=${{ env.git_commit_date }}
- name: Sign the published image - name: Sign the published image
if: ${{ false }} # github.event_name != 'pull_request' if: ${{ false }} # github.event_name != 'pull_request'
env: env:

1
.gitignore vendored
View File

@ -43,6 +43,7 @@ _tmp.csv
inventree/label.pdf inventree/label.pdf
inventree/label.png inventree/label.png
inventree/my_special* inventree/my_special*
_tests*.txt
# Sphinx files # Sphinx files
docs/_build docs/_build

View File

@ -19,17 +19,16 @@ repos:
hooks: hooks:
- id: flake8 - id: flake8
additional_dependencies: [ additional_dependencies: [
'flake8-bugbear',
'flake8-docstrings', 'flake8-docstrings',
'flake8-string-format', 'flake8-string-format',
'pep8-naming ', 'pep8-naming ',
] ]
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort
rev: '5.11.4' rev: '5.12.0'
hooks: hooks:
- id: isort - id: isort
- repo: https://github.com/jazzband/pip-tools - repo: https://github.com/jazzband/pip-tools
rev: 6.12.1 rev: 6.12.3
hooks: hooks:
- id: pip-compile - id: pip-compile
name: pip-compile requirements-dev.in name: pip-compile requirements-dev.in

View File

@ -23,7 +23,7 @@ docker compose run inventree-dev-server invoke setup-test
docker compose up -d docker compose up -d
``` ```
Read the [InvenTree setup documentation](https://inventree.readthedocs.io/en/latest/start/intro/) for a complete installation reference guide. Read the [InvenTree setup documentation](https://docs.inventree.org/en/latest/start/intro/) for a complete installation reference guide.
### Setup Devtools ### Setup Devtools

View File

@ -9,8 +9,7 @@
# - Runs InvenTree web server under django development server # - Runs InvenTree web server under django development server
# - Monitors source files for any changes, and live-reloads server # - Monitors source files for any changes, and live-reloads server
FROM python:3.9-slim as inventree_base
FROM python:3.9-slim as base
# Build arguments for this image # Build arguments for this image
ARG commit_hash="" ARG commit_hash=""
@ -19,9 +18,6 @@ ARG commit_tag=""
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
# Ref: https://github.com/pyca/cryptography/issues/5776
ENV CRYPTOGRAPHY_DONT_BUILD_RUST 1
ENV INVENTREE_LOG_LEVEL="WARNING" ENV INVENTREE_LOG_LEVEL="WARNING"
ENV INVENTREE_DOCKER="true" ENV INVENTREE_DOCKER="true"
@ -60,7 +56,7 @@ RUN apt-get update
# Install required system packages # Install required system packages
RUN apt-get install -y --no-install-recommends \ RUN apt-get install -y --no-install-recommends \
git gcc g++ gettext gnupg libffi-dev \ git gcc g++ gettext gnupg libffi-dev libssl-dev \
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11 # Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \ poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
# Image format support # Image format support
@ -76,6 +72,14 @@ RUN apt-get install -y --no-install-recommends \
# Update pip # Update pip
RUN pip install --upgrade pip RUN pip install --upgrade pip
# For ARMv7 architecture, add the pinwheels 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 \
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
fi
# Install required base-level python packages # Install required base-level python packages
COPY ./docker/requirements.txt base_requirements.txt COPY ./docker/requirements.txt base_requirements.txt
RUN pip install --disable-pip-version-check -U -r base_requirements.txt RUN pip install --disable-pip-version-check -U -r base_requirements.txt
@ -85,7 +89,7 @@ RUN pip install --disable-pip-version-check -U -r base_requirements.txt
# - Installs required python packages from requirements.txt # - Installs required python packages from requirements.txt
# - Starts a gunicorn webserver # - Starts a gunicorn webserver
FROM base as production FROM inventree_base as production
ENV INVENTREE_DEBUG=False ENV INVENTREE_DEBUG=False
@ -121,7 +125,7 @@ ENTRYPOINT ["/bin/bash", "./init.sh"]
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here # TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
FROM base as dev FROM inventree_base as dev
# The development image requires the source code to be mounted to /home/inventree/ # The development image requires the source code to be mounted to /home/inventree/
# So from here, we don't actually "do" anything, apart from some file management # So from here, we don't actually "do" anything, apart from some file management

View File

@ -5,16 +5,20 @@ from django.db import transaction
from django.http import JsonResponse from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_filters.rest_framework import DjangoFilterBackend
from django_q.models import OrmQ from django_q.models import OrmQ
from rest_framework import filters, permissions from rest_framework import permissions
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
import users.models
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI from InvenTree.mixins import ListCreateAPI
from InvenTree.permissions import RolePermission from InvenTree.permissions import RolePermission
from part.templatetags.inventree_extras import plugins_info from part.templatetags.inventree_extras import plugins_info
from plugin.serializers import MetadataSerializer
from .mixins import RetrieveUpdateAPI
from .status import is_worker_running from .status import is_worker_running
from .version import (inventreeApiVersion, inventreeInstanceName, from .version import (inventreeApiVersion, inventreeInstanceName,
inventreeVersion) inventreeVersion)
@ -197,14 +201,174 @@ class AttachmentMixin:
RolePermission, RolePermission,
] ]
filter_backends = [ filter_backends = SEARCH_ORDER_FILTER
DjangoFilterBackend,
filters.OrderingFilter,
filters.SearchFilter,
]
def perform_create(self, serializer): def perform_create(self, serializer):
"""Save the user information when a file is uploaded.""" """Save the user information when a file is uploaded."""
attachment = serializer.save() attachment = serializer.save()
attachment.user = self.request.user attachment.user = self.request.user
attachment.save() attachment.save()
class APISearchView(APIView):
"""A general-purpose 'search' API endpoint
Returns hits against a number of different models simultaneously,
to consolidate multiple API requests into a single query.
Is much more efficient and simplifies code!
"""
permission_classes = [
permissions.IsAuthenticated,
]
def get_result_types(self):
"""Construct a list of search types we can return"""
import build.api
import company.api
import order.api
import part.api
import stock.api
return {
'build': build.api.BuildList,
'company': company.api.CompanyList,
'manufacturerpart': company.api.ManufacturerPartList,
'supplierpart': company.api.SupplierPartList,
'part': part.api.PartList,
'partcategory': part.api.CategoryList,
'purchaseorder': order.api.PurchaseOrderList,
'returnorder': order.api.ReturnOrderList,
'salesorder': order.api.SalesOrderList,
'stockitem': stock.api.StockList,
'stocklocation': stock.api.StockLocationList,
}
def post(self, request, *args, **kwargs):
"""Perform search query against available models"""
data = request.data
results = {}
# These parameters are passed through to the individual queries, with optional default values
pass_through_params = {
'search': '',
'search_regex': False,
'search_whole': False,
'limit': 1,
'offset': 0,
}
for key, cls in self.get_result_types().items():
# Only return results which are specifically requested
if key in data:
params = data[key]
for k, v in pass_through_params.items():
params[k] = request.data.get(k, v)
# Enforce json encoding
params['format'] = 'json'
# Ignore if the params are wrong
if type(params) is not dict:
continue
view = cls()
# Override regular query params with specific ones for this search request
request._request.GET = params
view.request = request
view.format_kwarg = 'format'
# Check permissions and update results dict with particular query
model = view.serializer_class.Meta.model
app_label = model._meta.app_label
model_name = model._meta.model_name
table = f'{app_label}_{model_name}'
try:
if users.models.RuleSet.check_table_permission(request.user, table, 'view'):
results[key] = view.list(request, *args, **kwargs).data
else:
results[key] = {
'error': _('User does not have permission to view this model')
}
except Exception as exc:
results[key] = {
'error': str(exc)
}
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"""
MODEL_REF = 'model'
def get_model_type(self):
"""Return the model type associated with this API instance"""
model = self.kwargs.get(self.MODEL_REF, None)
if model is None:
raise ValidationError(f"MetadataView called without '{self.MODEL_REF}' parameter")
return model
def get_permission_model(self):
"""Return the 'permission' model associated with this view"""
return self.get_model_type()
def get_queryset(self):
"""Return the queryset for this endpoint"""
return self.get_model_type().objects.all()
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance"""
return MetadataSerializer(self.get_model_type(), *args, **kwargs)

View File

@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.http.response import StreamingHttpResponse from django.http.response import StreamingHttpResponse
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from plugin import registry from plugin import registry
@ -32,48 +33,69 @@ class UserMixin:
# Set list of roles automatically associated with the user # Set list of roles automatically associated with the user
roles = [] roles = []
def setUp(self): @classmethod
"""Setup for all tests.""" def setUpTestData(cls):
super().setUp() """Run setup for all tests in a given class"""
super().setUpTestData()
# Create a user to log in with # Create a user to log in with
self.user = get_user_model().objects.create_user( cls.user = get_user_model().objects.create_user(
username=self.username, username=cls.username,
password=self.password, password=cls.password,
email=self.email email=cls.email
) )
# Create a group for the user # Create a group for the user
self.group = Group.objects.create(name='my_test_group') cls.group = Group.objects.create(name='my_test_group')
self.user.groups.add(self.group) cls.user.groups.add(cls.group)
if self.superuser: if cls.superuser:
self.user.is_superuser = True cls.user.is_superuser = True
if self.is_staff: if cls.is_staff:
self.user.is_staff = True cls.user.is_staff = True
self.user.save() cls.user.save()
# Assign all roles if set # Assign all roles if set
if self.roles == 'all': if cls.roles == 'all':
self.assignRole(assign_all=True) cls.assignRole(group=cls.group, assign_all=True)
# else filter the roles # else filter the roles
else: else:
for role in self.roles: for role in cls.roles:
self.assignRole(role) cls.assignRole(role=role, group=cls.group)
def setUp(self):
"""Run setup for individual test methods"""
if self.auto_login: if self.auto_login:
self.client.login(username=self.username, password=self.password) self.client.login(username=self.username, password=self.password)
def assignRole(self, role=None, assign_all: bool = False): @classmethod
"""Set the user roles for the registered user.""" def assignRole(cls, role=None, assign_all: bool = False, group=None):
# role is of the format 'rule.permission' e.g. 'part.add' """Set the user roles for the registered user.
Arguments:
role: Role of the format 'rule.permission' e.g. 'part.add'
assign_all: Set to True to assign *all* roles
group: The group to assign roles to (or leave None to use the group assigned to this class)
"""
if group is None:
group = cls.group
if type(assign_all) is not bool:
# Raise exception if common mistake is made!
raise TypeError('assignRole: assign_all must be a boolean value')
if not role and not assign_all:
raise ValueError('assignRole: either role must be provided, or assign_all must be set')
if not assign_all and role: if not assign_all and role:
rule, perm = role.split('.') rule, perm = role.split('.')
for ruleset in self.group.rule_sets.all(): for ruleset in group.rule_sets.all():
if assign_all or ruleset.name == rule: if assign_all or ruleset.name == rule:
@ -105,9 +127,64 @@ class PluginMixin:
self.plugin_confs = PluginConfig.objects.all() self.plugin_confs = PluginConfig.objects.all()
class InvenTreeAPITestCase(UserMixin, APITestCase): class ExchangeRateMixin:
"""Mixin class for generating exchange rate data"""
def generate_exchange_rates(self):
"""Helper function which generates some exchange rates to work with"""
rates = {
'AUD': 1.5,
'CAD': 1.7,
'GBP': 0.9,
'USD': 1.0,
}
# Create a dummy backend
ExchangeBackend.objects.create(
name='InvenTreeExchange',
base_currency='USD',
)
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
items = []
for currency, rate in rates.items():
items.append(
Rate(
currency=currency,
value=rate,
backend=backend,
)
)
Rate.objects.bulk_create(items)
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Base class for running InvenTree API tests.""" """Base class for running InvenTree API tests."""
def checkResponse(self, url, method, expected_code, response):
"""Debug output for an unexpected response"""
# No expected code, return
if expected_code is None:
return
if expected_code != response.status_code:
print(f"Unexpected {method} response at '{url}': status_code = {response.status_code}")
if hasattr(response, 'data'):
print('data:', response.data)
if hasattr(response, 'body'):
print('body:', response.body)
if hasattr(response, 'content'):
print('content:', response.content)
self.assertEqual(expected_code, response.status_code)
def getActions(self, url): def getActions(self, url):
"""Return a dict of the 'actions' available at a given endpoint. """Return a dict of the 'actions' available at a given endpoint.
@ -131,13 +208,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
response = self.client.get(url, data, format=format) response = self.client.get(url, data, format=format)
if expected_code is not None: self.checkResponse(url, 'GET', expected_code, response)
if response.status_code != expected_code:
print(f"Unexpected response at '{url}': status_code = {response.status_code}")
print(response.data)
self.assertEqual(response.status_code, expected_code)
return response return response
@ -150,17 +221,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
response = self.client.post(url, data=data, format=format) response = self.client.post(url, data=data, format=format)
if expected_code is not None: self.checkResponse(url, 'POST', expected_code, response)
if response.status_code != expected_code:
print(f"Unexpected response at '{url}': status code = {response.status_code}")
if hasattr(response, 'data'):
print(response.data)
else:
print(f"(response object {type(response)} has no 'data' attribute")
self.assertEqual(response.status_code, expected_code)
return response return response
@ -172,8 +233,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
response = self.client.delete(url, data=data, format=format) response = self.client.delete(url, data=data, format=format)
if expected_code is not None: self.checkResponse(url, 'DELETE', expected_code, response)
self.assertEqual(response.status_code, expected_code)
return response return response
@ -181,8 +241,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
"""Issue a PATCH request.""" """Issue a PATCH request."""
response = self.client.patch(url, data=data, format=format) response = self.client.patch(url, data=data, format=format)
if expected_code is not None: self.checkResponse(url, 'PATCH', expected_code, response)
self.assertEqual(response.status_code, expected_code)
return response return response
@ -190,13 +249,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
"""Issue a PUT request.""" """Issue a PUT request."""
response = self.client.put(url, data=data, format=format) response = self.client.put(url, data=data, format=format)
if expected_code is not None: self.checkResponse(url, 'PUT', expected_code, response)
if response.status_code != expected_code:
print(f"Unexpected response at '{url}':")
print(response.data)
self.assertEqual(response.status_code, expected_code)
return response return response
@ -204,8 +257,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
"""Issue an OPTIONS request.""" """Issue an OPTIONS request."""
response = self.client.options(url, format='json') response = self.client.options(url, format='json')
if expected_code is not None: self.checkResponse(url, 'OPTIONS', expected_code, response)
self.assertEqual(response.status_code, expected_code)
return response return response

View File

@ -2,11 +2,55 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 94 INVENTREE_API_VERSION = 107
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v107 -> 2023-04-04 : https://github.com/inventree/InvenTree/pull/4575
- Adds barcode support for PurchaseOrder model
- Adds barcode support for ReturnOrder model
- Adds barcode support for SalesOrder model
- Adds barcode support for BuildOrder model
v106 -> 2023-04-03 : https://github.com/inventree/InvenTree/pull/4566
- Adds 'search_regex' parameter to all searchable API endpoints
v105 -> 2023-03-31 : https://github.com/inventree/InvenTree/pull/4543
- Adds API endpoints for status label information on various models
v104 -> 2023-03-23 : https://github.com/inventree/InvenTree/pull/4488
- Adds various endpoints for new "ReturnOrder" models
- Adds various endpoints for new "ReturnOrderReport" templates
- Exposes API endpoints for "Contact" model
v103 -> 2023-03-17 : https://github.com/inventree/InvenTree/pull/4410
- Add metadata to several more models
v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505
- Adds global search API endpoint for consolidated search results
v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462
- Adds 'total_in_stock' to Part serializer, and supports API ordering
v100 -> 2023-03-04 : https://github.com/inventree/InvenTree/pull/4452
- Adds bulk delete of PurchaseOrderLineItems to API
v99 -> 2023-03-03 : https://github.com/inventree/InvenTree/pull/4445
- Adds sort by "responsible" to PurchaseOrderAPI
v98 -> 2023-02-24 : https://github.com/inventree/InvenTree/pull/4408
- Adds "responsible" filter to Build API
v97 -> 2023-02-20 : https://github.com/inventree/InvenTree/pull/4377
- Adds "external" attribute to StockLocation model
v96 -> 2023-02-16 : https://github.com/inventree/InvenTree/pull/4345
- Adds stocktake report generation functionality
v95 -> 2023-02-16 : https://github.com/inventree/InvenTree/pull/4346
- Adds "CompanyAttachment" model (and associated API endpoints)
v94 -> 2023-02-10 : https://github.com/inventree/InvenTree/pull/4327 v94 -> 2023-02-10 : https://github.com/inventree/InvenTree/pull/4327
- Adds API endpoints for the "Group" auth model - Adds API endpoints for the "Group" auth model

View File

@ -12,10 +12,9 @@ from django.db import transaction
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
import InvenTree.tasks import InvenTree.tasks
from InvenTree.config import get_setting
from InvenTree.ready import canAppAccessDatabase, isInTestMode from InvenTree.ready import canAppAccessDatabase, isInTestMode
from .config import get_setting
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -24,8 +23,18 @@ class InvenTreeConfig(AppConfig):
name = 'InvenTree' name = 'InvenTree'
def ready(self): def ready(self):
"""Setup background tasks and update exchange rates.""" """Run system wide setup init steps.
Like:
- Checking if migrations should be run
- Cleaning up tasks
- Starting regular tasks
- Updateing exchange rates
- Collecting notification mehods
- Adding users set in the current environment
"""
if canAppAccessDatabase() or settings.TESTING_ENV: if canAppAccessDatabase() or settings.TESTING_ENV:
InvenTree.tasks.check_for_migrations(worker=False)
self.remove_obsolete_tasks() self.remove_obsolete_tasks()
@ -60,7 +69,10 @@ class InvenTreeConfig(AppConfig):
logger.info("Starting background tasks...") logger.info("Starting background tasks...")
for task in InvenTree.tasks.tasks.task_list: # List of collected tasks found with the @scheduled_task decorator
tasks = InvenTree.tasks.tasks.task_list
for task in tasks:
ref_name = f'{task.func.__module__}.{task.func.__name__}' ref_name = f'{task.func.__module__}.{task.func.__name__}'
InvenTree.tasks.schedule_task( InvenTree.tasks.schedule_task(
ref_name, ref_name,
@ -75,7 +87,7 @@ class InvenTreeConfig(AppConfig):
force_async=True, force_async=True,
) )
logger.info("Started background tasks...") logger.info(f"Started {len(tasks)} scheduled background tasks...")
def collect_tasks(self): def collect_tasks(self):
"""Collect all background tasks.""" """Collect all background tasks."""

View File

@ -1,6 +1,7 @@
"""Helper functions for loading InvenTree configuration options.""" """Helper functions for loading InvenTree configuration options."""
import datetime import datetime
import json
import logging import logging
import os import os
import random import random
@ -13,6 +14,47 @@ CONFIG_DATA = None
CONFIG_LOOKUPS = {} CONFIG_LOOKUPS = {}
def to_list(value, delimiter=','):
"""Take a configuration setting and make sure it is a list.
For example, we might have a configuration setting taken from the .config file,
which is already a list.
However, the same setting may be specified via an environment variable,
using a comma delimited string!
"""
if type(value) in [list, tuple]:
return value
# Otherwise, force string value
value = str(value)
return [x.strip() for x in value.split(delimiter)]
def to_dict(value):
"""Take a configuration setting and make sure it is a dict.
For example, we might have a configuration setting taken from the .config file,
which is already an object/dict.
However, the same setting may be specified via an environment variable,
using a valid JSON string!
"""
if value is None:
return {}
if type(value) == dict:
return value
try:
return json.loads(value)
except Exception as error:
logger.error(f"Failed to parse value '{value}' as JSON with error {error}. Ensure value is a valid JSON string.")
return {}
def is_true(x): def is_true(x):
"""Shortcut function to determine if a value "looks" like a boolean""" """Shortcut function to determine if a value "looks" like a boolean"""
return str(x).strip().lower() in ['1', 'y', 'yes', 't', 'true', 'on'] return str(x).strip().lower() in ['1', 'y', 'yes', 't', 'true', 'on']
@ -101,7 +143,16 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
""" """
def try_typecasting(value, source: str): def try_typecasting(value, source: str):
"""Attempt to typecast the value""" """Attempt to typecast the value"""
if typecast is not None:
# Force 'list' of strings
if typecast is list:
value = to_list(value)
# Valid JSON string is required
elif typecast is dict:
value = to_dict(value)
elif typecast is not None:
# Try to typecast the value # Try to typecast the value
try: try:
val = typecast(value) val = typecast(value)
@ -109,6 +160,7 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
return val return val
except Exception as error: except Exception as error:
logger.error(f"Failed to typecast '{env_var}' with value '{value}' to type '{typecast}' with error {error}") logger.error(f"Failed to typecast '{env_var}' with value '{value}' to type '{typecast}' with error {error}")
set_metadata(source) set_metadata(source)
return value return value

View File

@ -4,6 +4,7 @@
import InvenTree.status import InvenTree.status
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
ReturnOrderLineStatus, ReturnOrderStatus,
SalesOrderStatus, StockHistoryCode, SalesOrderStatus, StockHistoryCode,
StockStatus) StockStatus)
from users.models import RuleSet, check_user_role from users.models import RuleSet, check_user_role
@ -58,6 +59,8 @@ def status_codes(request):
return { return {
# Expose the StatusCode classes to the templates # Expose the StatusCode classes to the templates
'ReturnOrderStatus': ReturnOrderStatus,
'ReturnOrderLineStatus': ReturnOrderLineStatus,
'SalesOrderStatus': SalesOrderStatus, 'SalesOrderStatus': SalesOrderStatus,
'PurchaseOrderStatus': PurchaseOrderStatus, 'PurchaseOrderStatus': PurchaseOrderStatus,
'BuildStatus': BuildStatus, 'BuildStatus': BuildStatus,

View File

@ -55,10 +55,25 @@ def exception_handler(exc, context):
"""Custom exception handler for DRF framework. """Custom exception handler for DRF framework.
Ref: https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling Ref: https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling
Catches any errors not natively handled by DRF, and re-throws as an error DRF can handle Catches any errors not natively handled by DRF, and re-throws as an error DRF can handle.
If sentry error reporting is enabled, we will also provide the original exception to sentry.io
""" """
response = None response = None
if settings.SENTRY_ENABLED and settings.SENTRY_DSN and not settings.DEBUG:
# Report this exception to sentry.io
from sentry_sdk import capture_exception
# The following types of errors are ignored, they are "expected"
do_not_report = [
DjangoValidationError,
DRFValidationError,
]
if not any([isinstance(exc, err) for err in do_not_report]):
capture_exception(exc)
# Catch any django validation error, and re-throw a DRF validation error # Catch any django validation error, and re-throw a DRF validation error
if isinstance(exc, DjangoValidationError): if isinstance(exc, DjangoValidationError):
exc = DRFValidationError(detail=serializers.as_serializer_error(exc)) exc = DRFValidationError(detail=serializers.as_serializer_error(exc))

View File

@ -1,9 +1,66 @@
"""General filters for InvenTree.""" """General filters for InvenTree."""
from rest_framework.filters import OrderingFilter from django_filters import rest_framework as rest_filters
from rest_framework import filters
from InvenTree.helpers import str2bool
class InvenTreeOrderingFilter(OrderingFilter): class InvenTreeSearchFilter(filters.SearchFilter):
"""Custom search filter which allows adjusting of search terms dynamically"""
def get_search_fields(self, view, request):
"""Return a set of search fields for the request, adjusted based on request params.
The following query params are available to 'augment' the search (in decreasing order of priority)
- search_regex: If True, search is perfomed on 'regex' comparison
"""
regex = str2bool(request.query_params.get('search_regex', False))
search_fields = super().get_search_fields(view, request)
fields = []
if search_fields:
for field in search_fields:
if regex:
field = '$' + field
fields.append(field)
return fields
def get_search_terms(self, request):
"""Return the search terms for this search request.
Depending on the request parameters, we may "augment" these somewhat
"""
whole = str2bool(request.query_params.get('search_whole', False))
terms = []
search_terms = super().get_search_terms(request)
if search_terms:
for term in search_terms:
term = term.strip()
if not term:
# Ignore blank inputs
continue
if whole:
# Wrap the search term to enable word-boundary matching
term = r"\y" + term + r"\y"
terms.append(term)
return terms
class InvenTreeOrderingFilter(filters.OrderingFilter):
"""Custom OrderingFilter class which allows aliased filtering of related fields. """Custom OrderingFilter class which allows aliased filtering of related fields.
To use, simply specify this filter in the "filter_backends" section. To use, simply specify this filter in the "filter_backends" section.
@ -74,3 +131,21 @@ class InvenTreeOrderingFilter(OrderingFilter):
ordering.append(a) ordering.append(a)
return ordering return ordering
SEARCH_ORDER_FILTER = [
rest_filters.DjangoFilterBackend,
InvenTreeSearchFilter,
filters.OrderingFilter,
]
SEARCH_ORDER_FILTER_ALIAS = [
rest_filters.DjangoFilterBackend,
InvenTreeSearchFilter,
InvenTreeOrderingFilter,
]
ORDER_FILTER = [
rest_filters.DjangoFilterBackend,
filters.OrderingFilter,
]

View File

@ -126,6 +126,16 @@ class EditUserForm(HelperForm):
class SetPasswordForm(HelperForm): class SetPasswordForm(HelperForm):
"""Form for setting user password.""" """Form for setting user password."""
class Meta:
"""Metaclass options."""
model = User
fields = [
'enter_password',
'confirm_password',
'old_password',
]
enter_password = forms.CharField( enter_password = forms.CharField(
max_length=100, max_length=100,
min_length=8, min_length=8,
@ -152,16 +162,6 @@ class SetPasswordForm(HelperForm):
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}), widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}),
) )
class Meta:
"""Metaclass options."""
model = User
fields = [
'enter_password',
'confirm_password',
'old_password',
]
# override allauth # override allauth
class CustomSignupForm(SignupForm): class CustomSignupForm(SignupForm):

View File

@ -21,9 +21,11 @@ from django.http import StreamingHttpResponse
from django.test import TestCase from django.test import TestCase
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import moneyed.localization
import regex import regex
import requests import requests
from bleach import clean from bleach import clean
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money from djmoney.money import Money
from PIL import Image from PIL import Image
@ -33,7 +35,7 @@ from common.notifications import (InvenTreeNotificationBodies,
NotificationBody, trigger_notification) NotificationBody, trigger_notification)
from common.settings import currency_code_default from common.settings import currency_code_default
from .api_tester import UserMixin from .api_tester import ExchangeRateMixin, UserMixin
from .settings import MEDIA_URL, STATIC_URL from .settings import MEDIA_URL, STATIC_URL
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -523,6 +525,7 @@ def DownloadFile(data, filename, content_type='application/text', inline=False)
A StreamingHttpResponse object wrapping the supplied data A StreamingHttpResponse object wrapping the supplied data
""" """
filename = WrapWithQuotes(filename) filename = WrapWithQuotes(filename)
length = len(data)
if type(data) == str: if type(data) == str:
wrapper = FileWrapper(io.StringIO(data)) wrapper = FileWrapper(io.StringIO(data))
@ -530,7 +533,9 @@ def DownloadFile(data, filename, content_type='application/text', inline=False)
wrapper = FileWrapper(io.BytesIO(data)) wrapper = FileWrapper(io.BytesIO(data))
response = StreamingHttpResponse(wrapper, content_type=content_type) response = StreamingHttpResponse(wrapper, content_type=content_type)
response['Content-Length'] = len(data) if type(data) == str:
length = len(bytes(data, response.charset))
response['Content-Length'] = length
disposition = "inline" if inline else "attachment" disposition = "inline" if inline else "attachment"
@ -1059,7 +1064,7 @@ def inheritors(cls):
return subcls return subcls
class InvenTreeTestCase(UserMixin, TestCase): class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
"""Testcase with user setup buildin.""" """Testcase with user setup buildin."""
pass pass
@ -1105,3 +1110,54 @@ def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNo
target_exclude=[exclude], target_exclude=[exclude],
context=context, context=context,
) )
def render_currency(money, decimal_places=None, currency=None, include_symbol=True, min_decimal_places=None):
"""Render a currency / Money object to a formatted string (e.g. for reports)
Arguments:
money: The Money instance to be rendered
decimal_places: The number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
currency: Optionally convert to the specified currency
include_symbol: Render with the appropriate currency symbol
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
"""
if money in [None, '']:
return '-'
if type(money) is not Money:
return '-'
if currency is not None:
# Attempt to convert to the provided currency
# If cannot be done, leave the original
try:
money = convert_money(money, currency)
except Exception:
pass
if decimal_places is None:
decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6)
if min_decimal_places is None:
min_decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES_MIN', 0)
value = Decimal(str(money.amount)).normalize()
value = str(value)
if '.' in value:
decimals = len(value.split('.')[-1])
decimals = max(decimals, min_decimal_places)
decimals = min(decimals, decimal_places)
decimal_places = decimals
else:
decimal_places = max(decimal_places, 2)
return moneyed.localization.format_money(
money,
decimal_places=decimal_places,
include_symbol=include_symbol,
)

View File

@ -12,8 +12,15 @@ from django.utils.translation import override as lang_over
def render_file(file_name, source, target, locales, ctx): def render_file(file_name, source, target, locales, ctx):
"""Renders a file into all provided locales.""" """Renders a file into all provided locales."""
for locale in locales: for locale in locales:
# Enforce lower-case for locale names
locale = locale.lower()
locale = locale.replace('_', '-')
target_file = os.path.join(target, locale + '.' + file_name) target_file = os.path.join(target, locale + '.' + file_name)
with open(target_file, 'w') as localised_file: with open(target_file, 'w') as localised_file:
with lang_over(locale): with lang_over(locale):
renderd = render_to_string(os.path.join(source, file_name), ctx) renderd = render_to_string(os.path.join(source, file_name), ctx)

View File

@ -7,6 +7,7 @@ from rest_framework.fields import empty
from rest_framework.metadata import SimpleMetadata from rest_framework.metadata import SimpleMetadata
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
import InvenTree.permissions
import users.models import users.models
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
@ -58,7 +59,7 @@ class InvenTreeMetadata(SimpleMetadata):
try: try:
# Extract the model name associated with the view # Extract the model name associated with the view
self.model = view.serializer_class.Meta.model self.model = InvenTree.permissions.get_model_for_view(view)
# Construct the 'table name' from the model # Construct the 'table name' from the model
app_label = self.model._meta.app_label app_label = self.model._meta.app_label

View File

@ -0,0 +1,12 @@
# Generated by Django 3.2.15 on 2022-10-03 18:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
]
operations = [
]

View File

@ -116,6 +116,11 @@ class ReferenceIndexingMixin(models.Model):
# Name of the global setting which defines the required reference pattern for this model # Name of the global setting which defines the required reference pattern for this model
REFERENCE_PATTERN_SETTING = None REFERENCE_PATTERN_SETTING = None
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
@classmethod @classmethod
def get_reference_pattern(cls): def get_reference_pattern(cls):
"""Returns the reference pattern associated with this model. """Returns the reference pattern associated with this model.
@ -272,11 +277,6 @@ class ReferenceIndexingMixin(models.Model):
# Check that the reference field can be rebuild # Check that the reference field can be rebuild
cls.rebuild_reference_field(value, validate=True) cls.rebuild_reference_field(value, validate=True)
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
@classmethod @classmethod
def rebuild_reference_field(cls, reference, validate=False): def rebuild_reference_field(cls, reference, validate=False):
"""Extract integer out of reference for sorting. """Extract integer out of reference for sorting.
@ -369,6 +369,10 @@ class InvenTreeAttachment(models.Model):
upload_date: Date the file was uploaded upload_date: Date the file was uploaded
""" """
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
def getSubdir(self): def getSubdir(self):
"""Return the subdirectory under which attachments should be stored. """Return the subdirectory under which attachments should be stored.
@ -483,11 +487,6 @@ class InvenTreeAttachment(models.Model):
except Exception: except Exception:
raise ValidationError(_("Error renaming file")) raise ValidationError(_("Error renaming file"))
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
class InvenTreeTree(MPTTModel): class InvenTreeTree(MPTTModel):
"""Provides an abstracted self-referencing tree model for data categories. """Provides an abstracted self-referencing tree model for data categories.
@ -501,6 +500,33 @@ class InvenTreeTree(MPTTModel):
parent: The item immediately above this one. An item with a null parent is a top-level item parent: The item immediately above this one. An item with a null parent is a top-level item
""" """
class Meta:
"""Metaclass defines extra model properties."""
abstract = True
class MPTTMeta:
"""Set insert order."""
order_insertion_by = ['name']
def validate_unique(self, exclude=None):
"""Validate that this tree instance satisfies our uniqueness requirements.
Note that a 'unique_together' requirement for ('name', 'parent') is insufficient,
as it ignores cases where parent=None (i.e. top-level items)
"""
super().validate_unique(exclude)
results = self.__class__.objects.filter(
name=self.name,
parent=self.parent
).exclude(pk=self.pk)
if results.exists():
raise ValidationError({
'name': _('Duplicate names cannot exist under the same parent')
})
def api_instance_filters(self): def api_instance_filters(self):
"""Instance filters for InvenTreeTree models.""" """Instance filters for InvenTreeTree models."""
return { return {
@ -539,18 +565,6 @@ class InvenTreeTree(MPTTModel):
for child in self.get_children(): for child in self.get_children():
child.save(*args, **kwargs) child.save(*args, **kwargs)
class Meta:
"""Metaclass defines extra model properties."""
abstract = True
# Names must be unique at any given level in the tree
unique_together = ('name', 'parent')
class MPTTMeta:
"""Set insert order."""
order_insertion_by = ['name']
name = models.CharField( name = models.CharField(
blank=False, blank=False,
max_length=100, max_length=100,
@ -717,7 +731,7 @@ class InvenTreeBarcodeMixin(models.Model):
return cls.objects.filter(barcode_hash=barcode_hash).first() return cls.objects.filter(barcode_hash=barcode_hash).first()
def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True): def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True, save=True):
"""Assign an external (third-party) barcode to this object.""" """Assign an external (third-party) barcode to this object."""
# Must provide either barcode_hash or barcode_data # Must provide either barcode_hash or barcode_data
@ -740,7 +754,8 @@ class InvenTreeBarcodeMixin(models.Model):
self.barcode_hash = barcode_hash self.barcode_hash = barcode_hash
self.save() if save:
self.save()
return True return True

View File

@ -7,6 +7,21 @@ from rest_framework import permissions
import users.models import users.models
def get_model_for_view(view, raise_error=True):
"""Attempt to introspect the 'model' type for an API view"""
if hasattr(view, 'get_permission_model'):
return view.get_permission_model()
if hasattr(view, 'serializer_class'):
return view.serializer_class.Meta.model
if hasattr(view, 'get_serializer_class'):
return view.get_serializr_class().Meta.model
raise AttributeError(f"Serializer class not specified for {view.__class__}")
class RolePermission(permissions.BasePermission): class RolePermission(permissions.BasePermission):
"""Role mixin for API endpoints, allowing us to specify the user "role" which is required for certain operations. """Role mixin for API endpoints, allowing us to specify the user "role" which is required for certain operations.
@ -49,9 +64,13 @@ class RolePermission(permissions.BasePermission):
permission = rolemap[request.method] permission = rolemap[request.method]
# The required role may be defined for the view class
if role := getattr(view, 'role_required', None):
return users.models.check_user_role(user, role, permission)
try: try:
# Extract the model name associated with this request # Extract the model name associated with this request
model = view.serializer_class.Meta.model model = get_model_for_view(view)
app_label = model._meta.app_label app_label = model._meta.app_label
model_name = model._meta.model_name model_name = model._meta.model_name
@ -62,9 +81,7 @@ class RolePermission(permissions.BasePermission):
# then we don't need a permission # then we don't need a permission
return True return True
result = users.models.RuleSet.check_table_permission(user, table, permission) return users.models.RuleSet.check_table_permission(user, table, permission)
return result
class IsSuperuser(permissions.IsAdminUser): class IsSuperuser(permissions.IsAdminUser):

View File

@ -13,7 +13,7 @@ def isImportingData():
return 'loaddata' in sys.argv return 'loaddata' in sys.argv
def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False): def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False, allow_shell: bool = False):
"""Returns True if the apps.py file can access database records. """Returns True if the apps.py file can access database records.
There are some circumstances where we don't want the ready function in apps.py There are some circumstances where we don't want the ready function in apps.py
@ -26,7 +26,6 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False):
'loaddata', 'loaddata',
'dumpdata', 'dumpdata',
'check', 'check',
'shell',
'createsuperuser', 'createsuperuser',
'wait_for_db', 'wait_for_db',
'prerender', 'prerender',
@ -42,6 +41,9 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False):
'mediarestore', 'mediarestore',
] ]
if not allow_shell:
excluded_commands.append('shell')
if not allow_test: if not allow_test:
# Override for testing mode? # Override for testing mode?
excluded_commands.append('test') excluded_commands.append('test')

View File

@ -21,6 +21,7 @@ from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers import download_image_from_url from InvenTree.helpers import download_image_from_url
@ -66,6 +67,26 @@ class InvenTreeMoneySerializer(MoneyField):
return amount return amount
class InvenTreeCurrencySerializer(serializers.ChoiceField):
"""Custom serializers for selecting currency option"""
def __init__(self, *args, **kwargs):
"""Initialize the currency serializer"""
kwargs['choices'] = currency_code_mappings()
if 'default' not in kwargs and 'required' not in kwargs:
kwargs['default'] = currency_code_default
if 'label' not in kwargs:
kwargs['label'] = _('Currency')
if 'help_text' not in kwargs:
kwargs['help_text'] = _('Select currency from available options')
super().__init__(*args, **kwargs)
class InvenTreeModelSerializer(serializers.ModelSerializer): class InvenTreeModelSerializer(serializers.ModelSerializer):
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation.""" """Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
@ -282,6 +303,25 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
The only real addition here is that we support "renaming" of the attachment file. The only real addition here is that we support "renaming" of the attachment file.
""" """
@staticmethod
def attachment_fields(extra_fields=None):
"""Default set of fields for an attachment serializer"""
fields = [
'pk',
'attachment',
'filename',
'link',
'comment',
'upload_date',
'user',
'user_detail',
]
if extra_fields:
fields += extra_fields
return fields
user_detail = UserSerializer(source='user', read_only=True, many=False) user_detail = UserSerializer(source='user', read_only=True, many=False)
attachment = InvenTreeAttachmentSerializerField( attachment = InvenTreeAttachmentSerializerField(
@ -297,6 +337,8 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
allow_blank=False, allow_blank=False,
) )
upload_date = serializers.DateField(read_only=True)
class InvenTreeImageSerializerField(serializers.ImageField): class InvenTreeImageSerializerField(serializers.ImageField):
"""Custom image serializer. """Custom image serializer.

View File

@ -26,21 +26,31 @@ from sentry_sdk.integrations.django import DjangoIntegration
from . import config from . import config
from .config import get_boolean_setting, get_custom_file, get_setting from .config import get_boolean_setting, get_custom_file, get_setting
from .version import inventreeApiVersion
INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom' INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
# Determine if we are running in "test" mode e.g. "manage.py test" # Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv TESTING = 'test' in sys.argv
# Note: The following fix is "required" for docker build workflow if TESTING:
# Note: 2022-12-12 still unsure why...
if TESTING and os.getenv('INVENTREE_DOCKER'):
# Ensure that sys.path includes global python libs
site_packages = '/usr/local/lib/python3.9/site-packages'
if site_packages not in sys.path: # Use a weaker password hasher for testing (improves testing speed)
print("Adding missing site-packages path:", site_packages) PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher',]
sys.path.append(site_packages)
# Enable slow-test-runner
TEST_RUNNER = 'django_slowtests.testrunner.DiscoverSlowestTestsRunner'
NUM_SLOW_TESTS = 25
# Note: The following fix is "required" for docker build workflow
# Note: 2022-12-12 still unsure why...
if os.getenv('INVENTREE_DOCKER'):
# Ensure that sys.path includes global python libs
site_packages = '/usr/local/lib/python3.9/site-packages'
if site_packages not in sys.path:
print("Adding missing site-packages path:", site_packages)
sys.path.append(site_packages)
# Are environment variables manipulated by tests? Needs to be set by testing code # Are environment variables manipulated by tests? Needs to be set by testing code
TESTING_ENV = False TESTING_ENV = False
@ -102,8 +112,10 @@ MEDIA_ROOT = config.get_media_dir()
# List of allowed hosts (default = allow all) # List of allowed hosts (default = allow all)
ALLOWED_HOSTS = get_setting( ALLOWED_HOSTS = get_setting(
"INVENTREE_ALLOWED_HOSTS",
config_key='allowed_hosts', config_key='allowed_hosts',
default_value=['*'] default_value=['*'],
typecast=list,
) )
# Cross Origin Resource Sharing (CORS) options # Cross Origin Resource Sharing (CORS) options
@ -113,13 +125,16 @@ CORS_URLS_REGEX = r'^/api/.*$'
# Extract CORS options from configuration file # Extract CORS options from configuration file
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting( CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(
"INVENTREE_CORS_ORIGIN_ALLOW_ALL",
config_key='cors.allow_all', config_key='cors.allow_all',
default_value=False, default_value=False,
) )
CORS_ORIGIN_WHITELIST = get_setting( CORS_ORIGIN_WHITELIST = get_setting(
"INVENTREE_CORS_ORIGIN_WHITELIST",
config_key='cors.whitelist', config_key='cors.whitelist',
default_value=[] default_value=[],
typecast=list,
) )
# Needed for the parts importer, directly impacts the maximum parts that can be uploaded # Needed for the parts importer, directly impacts the maximum parts that can be uploaded
@ -219,6 +234,7 @@ INSTALLED_APPS = [
'django_otp.plugins.otp_static', # Backup codes 'django_otp.plugins.otp_static', # Backup codes
'allauth_2fa', # MFA flow for allauth 'allauth_2fa', # MFA flow for allauth
'drf_spectacular', # API documentation
'django_ical', # For exporting calendars 'django_ical', # For exporting calendars
] ]
@ -342,7 +358,7 @@ REST_FRAMEWORK = {
'rest_framework.permissions.DjangoModelPermissions', 'rest_framework.permissions.DjangoModelPermissions',
'InvenTree.permissions.RolePermission', 'InvenTree.permissions.RolePermission',
), ),
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata', 'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
'DEFAULT_RENDERER_CLASSES': [ 'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.JSONRenderer',
@ -353,6 +369,15 @@ if DEBUG:
# Enable browsable API if in DEBUG mode # Enable browsable API if in DEBUG mode
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer') REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer')
SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'},
'EXTERNAL_DOCS': {'docs': 'https://docs.inventree.org', 'web': 'https://inventree.org'},
'VERSION': inventreeApiVersion(),
'SERVE_INCLUDE_SCHEMA': False,
}
WSGI_APPLICATION = 'InvenTree.wsgi.application' WSGI_APPLICATION = 'InvenTree.wsgi.application'
""" """
@ -554,6 +579,9 @@ SENTRY_DSN = get_setting('INVENTREE_SENTRY_DSN', 'sentry_dsn', INVENTREE_DSN)
SENTRY_SAMPLE_RATE = float(get_setting('INVENTREE_SENTRY_SAMPLE_RATE', 'sentry_sample_rate', 0.1)) SENTRY_SAMPLE_RATE = float(get_setting('INVENTREE_SENTRY_SAMPLE_RATE', 'sentry_sample_rate', 0.1))
if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover
logger.info("Running with sentry.io integration enabled")
sentry_sdk.init( sentry_sdk.init(
dsn=SENTRY_DSN, dsn=SENTRY_DSN,
integrations=[DjangoIntegration(), ], integrations=[DjangoIntegration(), ],
@ -713,7 +741,7 @@ LANGUAGES = [
('th', _('Thai')), ('th', _('Thai')),
('tr', _('Turkish')), ('tr', _('Turkish')),
('vi', _('Vietnamese')), ('vi', _('Vietnamese')),
('zh-cn', _('Chinese')), ('zh-hans', _('Chinese')),
] ]
# Testing interface translations # Testing interface translations
@ -736,9 +764,11 @@ if get_boolean_setting('TEST_TRANSLATIONS', default_value=False): # pragma: no
django.conf.locale.LANG_INFO = LANG_INFO django.conf.locale.LANG_INFO = LANG_INFO
# Currencies available for use # Currencies available for use
CURRENCIES = get_setting('INVENTREE_CURRENCIES', 'currencies', [ CURRENCIES = get_setting(
'AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD', 'INVENTREE_CURRENCIES', 'currencies',
]) ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD'],
typecast=list,
)
# Maximum number of decimal places for currency rendering # Maximum number of decimal places for currency rendering
CURRENCY_DECIMAL_PLACES = 6 CURRENCY_DECIMAL_PLACES = 6
@ -746,7 +776,7 @@ CURRENCY_DECIMAL_PLACES = 6
# Check that each provided currency is supported # Check that each provided currency is supported
for currency in CURRENCIES: for currency in CURRENCIES:
if currency not in moneyed.CURRENCIES: # pragma: no cover if currency not in moneyed.CURRENCIES: # pragma: no cover
print(f"Currency code '{currency}' is not supported") logger.error(f"Currency code '{currency}' is not supported")
sys.exit(1) sys.exit(1)
# Custom currency exchange backend # Custom currency exchange backend
@ -795,15 +825,12 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
SITE_ID = 1 SITE_ID = 1
# Load the allauth social backends # Load the allauth social backends
SOCIAL_BACKENDS = get_setting('INVENTREE_SOCIAL_BACKENDS', 'social_backends', []) SOCIAL_BACKENDS = get_setting('INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list)
for app in SOCIAL_BACKENDS: for app in SOCIAL_BACKENDS:
INSTALLED_APPS.append(app) # pragma: no cover INSTALLED_APPS.append(app) # pragma: no cover
SOCIALACCOUNT_PROVIDERS = get_setting('INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None) SOCIALACCOUNT_PROVIDERS = get_setting('INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None, typecast=dict)
if SOCIALACCOUNT_PROVIDERS is None:
SOCIALACCOUNT_PROVIDERS = {}
SOCIALACCOUNT_STORE_TOKENS = True SOCIALACCOUNT_STORE_TOKENS = True
@ -894,7 +921,6 @@ CUSTOM_LOGO = get_custom_file('INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom
CUSTOM_SPLASH = get_custom_file('INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash') CUSTOM_SPLASH = get_custom_file('INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash')
CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {}) CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {})
if DEBUG: if DEBUG:
logger.info("InvenTree running with DEBUG enabled") logger.info("InvenTree running with DEBUG enabled")

View File

@ -315,9 +315,7 @@ main {
} }
.filter-button { .filter-button {
padding: 2px; padding: 6px;
padding-left: 4px;
padding-right: 4px;
} }
.filter-input { .filter-input {
@ -786,6 +784,12 @@ input[type="submit"] {
.alert-block { .alert-block {
display: block; display: block;
padding: 0.75rem;
}
.alert-small {
padding: 0.35rem;
font-size: 75%;
} }
.navbar .btn { .navbar .btn {

View File

@ -155,30 +155,37 @@ function inventreeDocReady() {
} }
/*
* Determine if a transfer (e.g. drag-and-drop) is a file transfer
*/
function isFileTransfer(transfer) { function isFileTransfer(transfer) {
/* Determine if a transfer (e.g. drag-and-drop) is a file transfer
*/
return transfer.files.length > 0; return transfer.files.length > 0;
} }
function enableDragAndDrop(element, url, options) { /* Enable drag-and-drop file uploading for a given element.
/* Enable drag-and-drop file uploading for a given element.
Params: Params:
element - HTML element lookup string e.g. "#drop-div" element - HTML element lookup string e.g. "#drop-div"
url - URL to POST the file to url - URL to POST the file to
options - object with following possible values: options - object with following possible values:
label - Label of the file to upload (default='file') label - Label of the file to upload (default='file')
data - Other form data to upload data - Other form data to upload
success - Callback function in case of success success - Callback function in case of success
error - Callback function in case of error error - Callback function in case of error
method - HTTP method method - HTTP method
*/ */
function enableDragAndDrop(elementId, url, options={}) {
var data = options.data || {}; var data = options.data || {};
let element = $(elementId);
if (!element.exists()) {
console.error(`enableDragAndDrop called with invalid target: '${elementId}'`);
return;
}
$(element).on('drop', function(event) { $(element).on('drop', function(event) {
var transfer = event.originalEvent.dataTransfer; var transfer = event.originalEvent.dataTransfer;
@ -200,6 +207,11 @@ function enableDragAndDrop(element, url, options) {
formData, formData,
{ {
success: function(data, status, xhr) { success: function(data, status, xhr) {
// Reload a table
if (options.refreshTable) {
reloadBootstrapTable(options.refreshTable);
}
if (options.success) { if (options.success) {
options.success(data, status, xhr); options.success(data, status, xhr);
} }

File diff suppressed because one or more lines are too long

View File

@ -61,19 +61,13 @@ def is_email_configured():
if not settings.TESTING: # pragma: no cover if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST is not configured") logger.debug("EMAIL_HOST is not configured")
if not settings.EMAIL_HOST_USER: # Display warning unless in test mode
configured = False if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST_USER is not configured")
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: # pragma: no cover if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST_USER is not configured") logger.debug("EMAIL_HOST_PASSWORD is not configured")
if not settings.EMAIL_HOST_PASSWORD:
configured = False
# Display warning unless in test mode
if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST_PASSWORD is not configured")
return configured return configured

View File

@ -31,23 +31,7 @@ class StatusCode:
@classmethod @classmethod
def list(cls): def list(cls):
"""Return the StatusCode options as a list of mapped key / value items.""" """Return the StatusCode options as a list of mapped key / value items."""
codes = [] return list(cls.dict().values())
for key in cls.options.keys():
opt = {
'key': key,
'value': cls.options[key]
}
color = cls.colors.get(key, None)
if color:
opt['color'] = color
codes.append(opt)
return codes
@classmethod @classmethod
def text(cls, key): def text(cls, key):
@ -69,6 +53,62 @@ class StatusCode:
"""All status code labels.""" """All status code labels."""
return cls.options.values() 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 @classmethod
def label(cls, value): def label(cls, value):
"""Return the status code label associated with the provided value.""" """Return the status code label associated with the provided value."""
@ -131,6 +171,7 @@ class SalesOrderStatus(StatusCode):
"""Defines a set of status codes for a SalesOrder.""" """Defines a set of status codes for a SalesOrder."""
PENDING = 10 # Order is pending PENDING = 10 # Order is pending
IN_PROGRESS = 15 # Order has been issued, and is in progress
SHIPPED = 20 # Order has been shipped to customer SHIPPED = 20 # Order has been shipped to customer
CANCELLED = 40 # Order has been cancelled CANCELLED = 40 # Order has been cancelled
LOST = 50 # Order was lost LOST = 50 # Order was lost
@ -138,6 +179,7 @@ class SalesOrderStatus(StatusCode):
options = { options = {
PENDING: _("Pending"), PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
SHIPPED: _("Shipped"), SHIPPED: _("Shipped"),
CANCELLED: _("Cancelled"), CANCELLED: _("Cancelled"),
LOST: _("Lost"), LOST: _("Lost"),
@ -146,6 +188,7 @@ class SalesOrderStatus(StatusCode):
colors = { colors = {
PENDING: 'secondary', PENDING: 'secondary',
IN_PROGRESS: 'primary',
SHIPPED: 'success', SHIPPED: 'success',
CANCELLED: 'danger', CANCELLED: 'danger',
LOST: 'warning', LOST: 'warning',
@ -155,6 +198,7 @@ class SalesOrderStatus(StatusCode):
# Open orders # Open orders
OPEN = [ OPEN = [
PENDING, PENDING,
IN_PROGRESS,
] ]
# Completed orders # Completed orders
@ -247,10 +291,14 @@ class StockHistoryCode(StatusCode):
BUILD_CONSUMED = 57 BUILD_CONSUMED = 57
# Sales order codes # Sales order codes
SHIPPED_AGAINST_SALES_ORDER = 60
# Purchase order codes # Purchase order codes
RECEIVED_AGAINST_PURCHASE_ORDER = 70 RECEIVED_AGAINST_PURCHASE_ORDER = 70
# Return order codes
RETURNED_AGAINST_RETURN_ORDER = 80
# Customer actions # Customer actions
SENT_TO_CUSTOMER = 100 SENT_TO_CUSTOMER = 100
RETURNED_FROM_CUSTOMER = 105 RETURNED_FROM_CUSTOMER = 105
@ -289,8 +337,11 @@ class StockHistoryCode(StatusCode):
BUILD_OUTPUT_COMPLETED: _('Build order output completed'), BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
BUILD_CONSUMED: _('Consumed by build order'), BUILD_CONSUMED: _('Consumed by build order'),
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase 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'),
} }
@ -320,3 +371,74 @@ class BuildStatus(StatusCode):
PENDING, PENDING,
PRODUCTION, PRODUCTION,
] ]
class ReturnOrderStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrder"""
# Order is pending, waiting for receipt of items
PENDING = 10
# Items have been received, and are being inspected
IN_PROGRESS = 20
COMPLETE = 30
CANCELLED = 40
OPEN = [
PENDING,
IN_PROGRESS,
]
options = {
PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
COMPLETE: _("Complete"),
CANCELLED: _("Cancelled"),
}
colors = {
PENDING: 'secondary',
IN_PROGRESS: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
}
class ReturnOrderLineStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrderLineItem"""
PENDING = 10
# Item is to be returned to customer, no other action
RETURN = 20
# Item is to be repaired, and returned to customer
REPAIR = 30
# Item is to be replaced (new item shipped)
REPLACE = 40
# Item is to be refunded (cannot be repaired)
REFUND = 50
# 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',
}

View File

@ -15,10 +15,17 @@ from django.conf import settings
from django.core import mail as django_mail from django.core import mail as django_mail
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady
from django.core.management import call_command from django.core.management import call_command
from django.db.utils import OperationalError, ProgrammingError from django.db import DEFAULT_DB_ALIAS, connections
from django.db.migrations.executor import MigrationExecutor
from django.db.utils import (NotSupportedError, OperationalError,
ProgrammingError)
from django.utils import timezone from django.utils import timezone
import requests import requests
from maintenance_mode.core import (get_maintenance_mode, maintenance_mode_on,
set_maintenance_mode)
from InvenTree.config import get_setting
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -67,6 +74,93 @@ def raise_warning(msg):
warnings.warn(msg) warnings.warn(msg)
def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
"""Check if a periodic task should be run, based on the provided setting name.
Arguments:
task_name: The name of the task being run, e.g. 'dummy_task'
setting_name: The name of the global setting, e.g. 'INVENTREE_DUMMY_TASK_INTERVAL'
Returns:
bool: If the task should be run *now*, or wait another day
This function will determine if the task should be run *today*,
based on when it was last run, or if we have a record of it running at all.
Note that this function creates some *hidden* global settings (designated with the _ prefix),
which are used to keep a running track of when the particular task was was last run.
"""
from common.models import InvenTreeSetting
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
time.sleep(random.randint(1, 5))
attempt_key = f'_{task_name}_ATTEMPT'
success_key = f'_{task_name}_SUCCESS'
# Check for recent success information
last_success = InvenTreeSetting.get_setting(success_key, '', cache=False)
if last_success:
try:
last_success = datetime.fromisoformat(last_success)
except ValueError:
last_success = None
if last_success:
threshold = datetime.now() - timedelta(days=n_days)
if last_success > threshold:
logger.info(f"Last successful run for '{task_name}' was too recent - skipping task")
return False
# Check for any information we have about this task
last_attempt = InvenTreeSetting.get_setting(attempt_key, '', cache=False)
if last_attempt:
try:
last_attempt = datetime.fromisoformat(last_attempt)
except ValueError:
last_attempt = None
if last_attempt:
# Do not attempt if the most recent *attempt* was within 12 hours
threshold = datetime.now() - timedelta(hours=12)
if last_attempt > threshold:
logger.info(f"Last attempt for '{task_name}' was too recent - skipping task")
return False
# Record this attempt
record_task_attempt(task_name)
# No reason *not* to run this task now
return True
def record_task_attempt(task_name: str):
"""Record that a multi-day task has been attempted *now*"""
from common.models import InvenTreeSetting
logger.info(f"Logging task attempt for '{task_name}'")
InvenTreeSetting.set_setting(f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None)
def record_task_success(task_name: str):
"""Record that a multi-day task was successful *now*"""
from common.models import InvenTreeSetting
InvenTreeSetting.set_setting(f'_{task_name}_SUCCESS', datetime.now().isoformat(), None)
def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs): def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs):
"""Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once! """Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once!
@ -341,6 +435,14 @@ def check_for_updates():
logger.info("Could not perform 'check_for_updates' - App registry not ready") logger.info("Could not perform 'check_for_updates' - App registry not ready")
return return
interval = int(common.models.InvenTreeSetting.get_setting('INVENTREE_UPDATE_CHECK_INTERVAL', 7, cache=False))
# Check if we should check for updates *today*
if not check_daily_holdoff('check_for_updates', interval):
return
logger.info("Checking for InvenTree software updates")
headers = {} headers = {}
# If running within github actions, use authentication token # If running within github actions, use authentication token
@ -350,7 +452,10 @@ def check_for_updates():
if token: if token:
headers['Authorization'] = f"Bearer {token}" headers['Authorization'] = f"Bearer {token}"
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest', headers=headers) response = requests.get(
'https://api.github.com/repos/inventree/inventree/releases/latest',
headers=headers
)
if response.status_code != 200: if response.status_code != 200:
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover
@ -377,11 +482,14 @@ def check_for_updates():
# Save the version to the database # Save the version to the database
common.models.InvenTreeSetting.set_setting( common.models.InvenTreeSetting.set_setting(
'INVENTREE_LATEST_VERSION', '_INVENTREE_LATEST_VERSION',
tag, tag,
None None
) )
# Record that this task was successful
record_task_success('check_for_updates')
@scheduled_task(ScheduledTask.DAILY) @scheduled_task(ScheduledTask.DAILY)
def update_exchange_rates(): def update_exchange_rates():
@ -428,68 +536,26 @@ def update_exchange_rates():
@scheduled_task(ScheduledTask.DAILY) @scheduled_task(ScheduledTask.DAILY)
def run_backup(): def run_backup():
"""Run the backup command.""" """Run the backup command."""
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
if not InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE', False, cache=False): if not InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE', False, cache=False):
# Backups are not enabled - exit early # Backups are not enabled - exit early
return return
logger.info("Performing automated database backup task") interval = int(InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False))
# Sleep a random number of seconds to prevent worker conflict # Check if should run this task *today*
time.sleep(random.randint(1, 5)) if not check_daily_holdoff('run_backup', interval):
# Check for records of previous backup attempts
last_attempt = InvenTreeSetting.get_setting('INVENTREE_BACKUP_ATTEMPT', '', cache=False)
last_success = InvenTreeSetting.get_setting('INVENTREE_BACKUP_SUCCESS', '', cache=False)
try:
backup_n_days = int(InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False))
except Exception:
backup_n_days = 1
if last_attempt:
try:
last_attempt = datetime.fromisoformat(last_attempt)
except ValueError:
last_attempt = None
if last_attempt:
# Do not attempt if the 'last attempt' at backup was within 12 hours
threshold = timezone.now() - timezone.timedelta(hours=12)
if last_attempt > threshold:
logger.info('Last backup attempt was too recent - skipping backup operation')
return
# Record the timestamp of most recent backup attempt
InvenTreeSetting.set_setting('INVENTREE_BACKUP_ATTEMPT', timezone.now().isoformat(), None)
if not last_attempt:
# If there is no record of a previous attempt, exit quickly
# This prevents the backup operation from happening when the server first launches, for example
logger.info("No previous backup attempts recorded - waiting until tomorrow")
return return
if last_success: logger.info("Performing automated database backup task")
try:
last_success = datetime.fromisoformat(last_success)
except ValueError:
last_success = None
# Exit early if the backup was successful within the number of required days
if last_success:
threshold = timezone.now() - timezone.timedelta(days=backup_n_days)
if last_success > threshold:
logger.info('Last successful backup was too recent - skipping backup operation')
return
call_command("dbbackup", noinput=True, clean=True, compress=True, interactive=False) call_command("dbbackup", noinput=True, clean=True, compress=True, interactive=False)
call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False) call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False)
# Record the timestamp of most recent backup success # Record that this task was successful
InvenTreeSetting.set_setting('INVENTREE_BACKUP_SUCCESS', datetime.now().isoformat(), None) record_task_success('run_backup')
def send_email(subject, body, recipients, from_email=None, html_message=None): def send_email(subject, body, recipients, from_email=None, html_message=None):
@ -506,3 +572,74 @@ def send_email(subject, body, recipients, from_email=None, html_message=None):
fail_silently=False, fail_silently=False,
html_message=html_message html_message=html_message
) )
@scheduled_task(ScheduledTask.DAILY)
def check_for_migrations(worker: bool = True):
"""Checks if migrations are needed.
If the setting auto_update is enabled we will start updateing.
"""
# Test if auto-updates are enabled
if not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
return
from plugin import registry
plan = get_migration_plan()
# Check if there are any open migrations
if not plan:
logger.info('There are no open migrations')
return
logger.info('There are open migrations')
# Log open migrations
for migration in plan:
logger.info(migration[0])
# Set the application to maintenance mode - no access from now on.
logger.info('Going into maintenance')
set_maintenance_mode(True)
logger.info('Mainentance mode is on now')
# Check if we are worker - go kill all other workers then.
# Only the frontend workers run updates.
if worker:
logger.info('Current process is a worker - shutting down cluster')
# Ok now we are ready to go ahead!
# To be sure we are in maintenance this is wrapped
with maintenance_mode_on():
logger.info('Starting migrations')
print('Starting migrations')
try:
call_command('migrate', interactive=False)
except NotSupportedError as e: # pragma: no cover
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
raise e
logger.error(f'Error during migrations: {e}')
print('Migrations done')
logger.info('Ran migrations')
# Make sure we are out of maintenance again
logger.info('Checking InvenTree left maintenance mode')
if get_maintenance_mode():
logger.warning('Mainentance was still on - releasing now')
set_maintenance_mode(False)
logger.info('Released out of maintenance')
# We should be current now - triggering full reload to make sure all models
# are loaded fully in their new state.
registry.reload_plugins(full_reload=True, force_reload=True)
def get_migration_plan():
"""Returns a list of migrations which are needed to be run."""
executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
return plan

View File

@ -8,7 +8,7 @@ from rest_framework import status
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.helpers import InvenTreeTestCase from InvenTree.helpers import InvenTreeTestCase
from users.models import RuleSet from users.models import RuleSet, update_group_roles
class HTMLAPITests(InvenTreeTestCase): class HTMLAPITests(InvenTreeTestCase):
@ -126,6 +126,10 @@ class APITests(InvenTreeAPITestCase):
""" """
url = reverse('api-user-roles') url = reverse('api-user-roles')
# Delete all rules
self.group.rule_sets.all().delete()
update_group_roles(self.group)
response = self.client.get(url, format='json') response = self.client.get(url, format='json')
# Not logged in, so cannot access user role data # Not logged in, so cannot access user role data
@ -297,3 +301,133 @@ class BulkDeleteTests(InvenTreeAPITestCase):
) )
self.assertIn("'filters' must be supplied as a dict object", str(response.data)) self.assertIn("'filters' must be supplied as a dict object", str(response.data))
class SearchTests(InvenTreeAPITestCase):
"""Unit tests for global search endpoint"""
fixtures = [
'category',
'part',
'company',
'location',
'supplier_part',
'stock',
'order',
'sales_order',
]
def test_results(self):
"""Test individual result types"""
response = self.post(
reverse('api-search'),
{
'search': 'chair',
'limit': 3,
'part': {},
'build': {},
},
expected_code=200
)
# No build results
self.assertEqual(response.data['build']['count'], 0)
# 3 (of 5) part results
self.assertEqual(response.data['part']['count'], 5)
self.assertEqual(len(response.data['part']['results']), 3)
# Other results not included
self.assertNotIn('purchaseorder', response.data)
self.assertNotIn('salesorder', response.data)
# Search for orders
response = self.post(
reverse('api-search'),
{
'search': '01',
'limit': 2,
'purchaseorder': {},
'salesorder': {},
},
expected_code=200,
)
self.assertEqual(response.data['purchaseorder']['count'], 1)
self.assertEqual(response.data['salesorder']['count'], 0)
self.assertNotIn('stockitem', response.data)
self.assertNotIn('build', response.data)
def test_permissions(self):
"""Test that users with insufficient permissions are handled correctly"""
# First, remove all roles
for ruleset in self.group.rule_sets.all():
ruleset.can_view = False
ruleset.can_change = False
ruleset.can_delete = False
ruleset.can_add = False
ruleset.save()
models = [
'build',
'company',
'manufacturerpart',
'supplierpart',
'part',
'partcategory',
'purchaseorder',
'stockitem',
'stocklocation',
'salesorder',
]
query = {
'search': 'c',
'limit': 3,
}
for mdl in models:
query[mdl] = {}
response = self.post(
reverse('api-search'),
query,
expected_code=200
)
# Check for 'permission denied' error
for mdl in models:
self.assertEqual(response.data[mdl]['error'], 'User does not have permission to view this model')
# Assign view roles for some parts
self.assignRole('build.view')
self.assignRole('part.view')
response = self.post(
reverse('api-search'),
query,
expected_code=200
)
# Check for expected results, based on permissions
# We expect results to be returned for the following model types
has_permission = [
'build',
'manufacturerpart',
'supplierpart',
'part',
'partcategory',
'stocklocation',
'stockitem',
]
for mdl in models:
result = response.data[mdl]
if mdl in has_permission:
self.assertIn('count', result)
else:
self.assertIn('error', result)
self.assertEqual(result['error'], 'User does not have permission to view this model')

View File

@ -1,7 +1,11 @@
"""Unit tests for task management.""" """Unit tests for task management."""
import os
from datetime import timedelta from datetime import timedelta
from django.conf import settings
from django.core.management import call_command
from django.db.utils import NotSupportedError
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
@ -108,12 +112,41 @@ class InvenTreeTaskTests(TestCase):
def test_task_check_for_updates(self): def test_task_check_for_updates(self):
"""Test the task check_for_updates.""" """Test the task check_for_updates."""
# Check that setting should be empty # Check that setting should be empty
self.assertEqual(InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION'), '') self.assertEqual(InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION'), '')
# Get new version # Get new version
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates) InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates)
# Check that setting is not empty # Check that setting is not empty
response = InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION') response = InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION')
self.assertNotEqual(response, '') self.assertNotEqual(response, '')
self.assertTrue(bool(response)) self.assertTrue(bool(response))
def test_task_check_for_migrations(self):
"""Test the task check_for_migrations."""
# Update disabled
InvenTree.tasks.check_for_migrations()
# Update enabled - no migrations
os.environ['INVENTREE_AUTO_UPDATE'] = 'True'
InvenTree.tasks.check_for_migrations()
# Create migration
self.assertEqual(len(InvenTree.tasks.get_migration_plan()), 0)
call_command('makemigrations', ['InvenTree', '--empty'], interactive=False)
self.assertEqual(len(InvenTree.tasks.get_migration_plan()), 1)
# Run with migrations - catch no foreigner error
try:
InvenTree.tasks.check_for_migrations()
except NotSupportedError as e: # pragma: no cover
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
raise e
# Cleanup
try:
migration_name = InvenTree.tasks.get_migration_plan()[0][0].name + '.py'
migration_path = settings.BASE_DIR / 'InvenTree' / 'migrations' / migration_name
migration_path.unlink()
except IndexError: # pragma: no cover
pass

View File

@ -3,6 +3,7 @@
import json import json
import os import os
import time import time
from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
from unittest import mock from unittest import mock
@ -29,20 +30,12 @@ from stock.models import StockItem, StockLocation
from . import config, helpers, ready, status, version from . import config, helpers, ready, status, version
from .tasks import offload_task from .tasks import offload_task
from .validators import validate_overage, validate_part_name from .validators import validate_overage
class ValidatorTest(TestCase): class ValidatorTest(TestCase):
"""Simple tests for custom field validators.""" """Simple tests for custom field validators."""
def test_part_name(self):
"""Test part name validator."""
validate_part_name('hello world')
# Validate with some strange chars
with self.assertRaises(django_exceptions.ValidationError):
validate_part_name('### <> This | name is not } valid')
def test_overage(self): def test_overage(self):
"""Test overage validator.""" """Test overage validator."""
validate_overage("100%") validate_overage("100%")
@ -398,10 +391,10 @@ class TestMPTT(TestCase):
'location', 'location',
] ]
def setUp(self): @classmethod
def setUpTestData(cls):
"""Setup for all tests.""" """Setup for all tests."""
super().setUp() super().setUpTestData()
StockLocation.objects.rebuild() StockLocation.objects.rebuild()
def test_self_as_parent(self): def test_self_as_parent(self):
@ -830,6 +823,17 @@ class TestSettings(helpers.InvenTreeTestCase):
with self.in_env_context({TEST_ENV_NAME: '321'}): with self.in_env_context({TEST_ENV_NAME: '321'}):
self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321') self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321')
# test typecasting to dict - None should be mapped to empty dict
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, None, typecast=dict), {})
# test typecasting to dict - valid JSON string should be mapped to corresponding dict
with self.in_env_context({TEST_ENV_NAME: '{"a": 1}'}):
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, typecast=dict), {"a": 1})
# test typecasting to dict - invalid JSON string should be mapped to empty dict
with self.in_env_context({TEST_ENV_NAME: "{'a': 1}"}):
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, typecast=dict), {})
class TestInstanceName(helpers.InvenTreeTestCase): class TestInstanceName(helpers.InvenTreeTestCase):
"""Unit tests for instance name.""" """Unit tests for instance name."""
@ -903,6 +907,58 @@ class TestOffloadTask(helpers.InvenTreeTestCase):
force_async=True force_async=True
) )
def test_daily_holdoff(self):
"""Tests for daily task holdoff helper functions"""
import InvenTree.tasks
with self.assertLogs(logger='inventree', level='INFO') as cm:
# With a non-positive interval, task will not run
result = InvenTree.tasks.check_daily_holdoff('some_task', 0)
self.assertFalse(result)
self.assertIn('Specified interval', str(cm.output))
with self.assertLogs(logger='inventree', level='INFO') as cm:
# First call should run without issue
result = InvenTree.tasks.check_daily_holdoff('dummy_task')
self.assertTrue(result)
self.assertIn("Logging task attempt for 'dummy_task'", str(cm.output))
with self.assertLogs(logger='inventree', level='INFO') as cm:
# An attempt has been logged, but it is too recent
result = InvenTree.tasks.check_daily_holdoff('dummy_task')
self.assertFalse(result)
self.assertIn("Last attempt for 'dummy_task' was too recent", str(cm.output))
# Mark last attempt a few days ago - should now return True
t_old = datetime.now() - timedelta(days=3)
t_old = t_old.isoformat()
InvenTreeSetting.set_setting('_dummy_task_ATTEMPT', t_old, None)
result = InvenTree.tasks.check_daily_holdoff('dummy_task', 5)
self.assertTrue(result)
# Last attempt should have been updated
self.assertNotEqual(t_old, InvenTreeSetting.get_setting('_dummy_task_ATTEMPT', '', cache=False))
# Last attempt should prevent us now
with self.assertLogs(logger='inventree', level='INFO') as cm:
result = InvenTree.tasks.check_daily_holdoff('dummy_task')
self.assertFalse(result)
self.assertIn("Last attempt for 'dummy_task' was too recent", str(cm.output))
# Configure so a task was successful too recently
InvenTreeSetting.set_setting('_dummy_task_ATTEMPT', t_old, None)
InvenTreeSetting.set_setting('_dummy_task_SUCCESS', t_old, None)
with self.assertLogs(logger='inventree', level='INFO') as cm:
result = InvenTree.tasks.check_daily_holdoff('dummy_task', 7)
self.assertFalse(result)
self.assertIn('Last successful run for', str(cm.output))
result = InvenTree.tasks.check_daily_holdoff('dummy_task', 2)
self.assertTrue(result)
class BarcodeMixinTest(helpers.InvenTreeTestCase): class BarcodeMixinTest(helpers.InvenTreeTestCase):
"""Tests for the InvenTreeBarcodeMixin mixin class""" """Tests for the InvenTreeBarcodeMixin mixin class"""

View File

@ -9,7 +9,7 @@ from django.contrib import admin
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from rest_framework.documentation import include_docs_urls from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from build.api import build_api_urls from build.api import build_api_urls
from build.urls import build_urls from build.urls import build_urls
@ -30,7 +30,7 @@ from stock.api import stock_api_urls
from stock.urls import stock_urls from stock.urls import stock_urls
from users.api import user_urls from users.api import user_urls
from .api import InfoView, NotFoundView from .api import APISearchView, InfoView, NotFoundView
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView, from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
CustomEmailView, CustomLoginView, CustomEmailView, CustomLoginView,
CustomPasswordResetFromKeyView, CustomPasswordResetFromKeyView,
@ -43,6 +43,10 @@ admin.site.site_header = "InvenTree Admin"
apipatterns = [ apipatterns = [
# Global search
path('search/', APISearchView.as_view(), name='api-search'),
re_path(r'^settings/', include(settings_api_urls)), re_path(r'^settings/', include(settings_api_urls)),
re_path(r'^part/', include(part_api_urls)), re_path(r'^part/', include(part_api_urls)),
re_path(r'^bom/', include(bom_api_urls)), re_path(r'^bom/', include(bom_api_urls)),
@ -58,9 +62,12 @@ apipatterns = [
# Plugin endpoints # Plugin endpoints
path('', include(plugin_api_urls)), path('', include(plugin_api_urls)),
# Webhook enpoint # Common endpoints enpoint
path('', include(common_api_urls)), path('', include(common_api_urls)),
# OpenAPI Schema
re_path('schema/', SpectacularAPIView.as_view(custom_settings={'SCHEMA_PATH_PREFIX': '/api/'}), name='schema'),
# InvenTree information endpoint # InvenTree information endpoint
path('', InfoView.as_view(), name='api-inventree-info'), path('', InfoView.as_view(), name='api-inventree-info'),
@ -108,9 +115,13 @@ translated_javascript_urls = [
re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'), re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
re_path(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'), re_path(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'),
re_path(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'), re_path(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
re_path(r'^purchase_order.js', DynamicJsView.as_view(template_name='js/translated/purchase_order.js'), name='purchase_order.js'),
re_path(r'^return_order.js', DynamicJsView.as_view(template_name='js/translated/return_order.js'), name='return_order.js'),
re_path(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'), re_path(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
re_path(r'^sales_order.js', DynamicJsView.as_view(template_name='js/translated/sales_order.js'), name='sales_order.js'),
re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'), re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'),
re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'), re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
re_path(r'^status_codes.js', DynamicJsView.as_view(template_name='js/translated/status_codes.js'), name='status_codes.js'),
re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'), re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'), re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'),
re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'), re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'),
@ -128,7 +139,7 @@ backendpatterns = [
re_path(r'^auth/?', auth_request), re_path(r'^auth/?', auth_request),
re_path(r'^api/', include(apipatterns)), re_path(r'^api/', include(apipatterns)),
re_path(r'^api-doc/', include_docs_urls(title='InvenTree API')), re_path(r'^api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
] ]
frontendpatterns = [ frontendpatterns = [

View File

@ -11,8 +11,6 @@ from django.utils.translation import gettext_lazy as _
from jinja2 import Template from jinja2 import Template
from moneyed import CURRENCIES from moneyed import CURRENCIES
import common.models
def validate_currency_code(code): def validate_currency_code(code):
"""Check that a given code is a valid currency code.""" """Check that a given code is a valid currency code."""
@ -47,50 +45,6 @@ class AllowedURLValidator(validators.URLValidator):
super().__call__(value) super().__call__(value)
def validate_part_name(value):
"""Validate the name field for a Part instance
This function is exposed to any Validation plugins, and thus can be customized.
"""
from plugin.registry import registry
for plugin in registry.with_mixin('validation'):
# Run the name through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
if plugin.validate_part_name(value):
return
def validate_part_ipn(value):
"""Validate the IPN field for a Part instance.
This function is exposed to any Validation plugins, and thus can be customized.
If no validation errors are raised, the IPN is also validated against a configurable regex pattern.
"""
from plugin.registry import registry
plugins = registry.with_mixin('validation')
for plugin in plugins:
# Run the IPN through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
if plugin.validate_part_ipn(value):
return
# If we get to here, none of the plugins have raised an error
pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX')
if pattern:
match = re.search(pattern, value)
if match is None:
raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern))
def validate_purchase_order_reference(value): def validate_purchase_order_reference(value):
"""Validate the 'reference' field of a PurchaseOrder.""" """Validate the 'reference' field of a PurchaseOrder."""

View File

@ -9,20 +9,23 @@ import subprocess
import django import django
import common.models
from InvenTree.api_version import INVENTREE_API_VERSION from InvenTree.api_version import INVENTREE_API_VERSION
# InvenTree software version # InvenTree software version
INVENTREE_SW_VERSION = "0.11.0 dev" INVENTREE_SW_VERSION = "0.12.0 dev"
def inventreeInstanceName(): def inventreeInstanceName():
"""Returns the InstanceName settings for the current database.""" """Returns the InstanceName settings for the current database."""
import common.models
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "") return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
def inventreeInstanceTitle(): def inventreeInstanceTitle():
"""Returns the InstanceTitle for the current database.""" """Returns the InstanceTitle for the current database."""
import common.models
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False): if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "") return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
else: else:
@ -64,9 +67,10 @@ def inventreeDocsVersion():
def isInvenTreeUpToDate(): def isInvenTreeUpToDate():
"""Test if the InvenTree instance is "up to date" with the latest version. """Test if the InvenTree instance is "up to date" with the latest version.
A background task periodically queries GitHub for latest version, and stores it to the database as INVENTREE_LATEST_VERSION A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION"
""" """
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False) import common.models
latest = common.models.InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION', backup_value=None, create=False)
# No record for "latest" version - we must assume we are up to date! # No record for "latest" version - we must assume we are up to date!
if not latest: if not latest:

View File

@ -320,44 +320,6 @@ class AjaxView(AjaxMixin, View):
return self.renderJsonResponse(request) return self.renderJsonResponse(request)
class QRCodeView(AjaxView):
"""An 'AJAXified' view for displaying a QR code.
Subclasses should implement the get_qr_data(self) function.
"""
ajax_template_name = "qr_code.html"
def get(self, request, *args, **kwargs):
"""Return json with qr-code data."""
self.request = request
self.pk = self.kwargs['pk']
return self.renderJsonResponse(request, None, context=self.get_context_data())
def get_qr_data(self):
"""Returns the text object to render to a QR code.
The actual rendering will be handled by the template
"""
return None
def get_context_data(self):
"""Get context data for passing to the rendering template.
Explicity passes the parameter 'qr_data'
"""
context = {}
qr = self.get_qr_data()
if qr:
context['qr_data'] = qr
else:
context['error_msg'] = 'Error generating QR code'
return context
class AjaxUpdateView(AjaxMixin, UpdateView): class AjaxUpdateView(AjaxMixin, UpdateView):
"""An 'AJAXified' UpdateView for updating an object in the db. """An 'AJAXified' UpdateView for updating an object in the db.

View File

@ -17,7 +17,18 @@ class BuildResource(InvenTreeResource):
# but we don't for other ones. # but we don't for other ones.
# TODO: 2022-05-12 - Need to investigate why this is the case! # TODO: 2022-05-12 - Need to investigate why this is the case!
id = Field(attribute='pk') class Meta:
"""Metaclass options"""
models = Build
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = [
'lft', 'rght', 'tree_id', 'level',
'metadata',
]
id = Field(attribute='pk', widget=widgets.IntegerWidget())
reference = Field(attribute='reference') reference = Field(attribute='reference')
@ -39,17 +50,6 @@ class BuildResource(InvenTreeResource):
notes = Field(attribute='notes') notes = Field(attribute='notes')
class Meta:
"""Metaclass options"""
models = Build
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = [
'lft', 'rght', 'tree_id', 'level',
'metadata',
]
class BuildAdmin(ImportExportModelAdmin): class BuildAdmin(ImportExportModelAdmin):
"""Class for managing the Build model via the admin interface""" """Class for managing the Build model via the admin interface"""

View File

@ -1,30 +1,39 @@
"""JSON API for the Build app.""" """JSON API for the Build app."""
from django.urls import include, re_path from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
from rest_framework import filters
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView, StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import build.admin import build.admin
import build.serializers import build.serializers
from build.models import Build, BuildItem, BuildOrderAttachment from build.models import Build, BuildItem, BuildOrderAttachment
import part.models
from users.models import Owner from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
class BuildFilter(rest_filters.FilterSet): class BuildFilter(rest_filters.FilterSet):
"""Custom filterset for BuildList API endpoint.""" """Custom filterset for BuildList API endpoint."""
class Meta:
"""Metaclass options"""
model = Build
fields = [
'parent',
'sales_order',
'part',
]
status = rest_filters.NumberFilter(label='Status') status = rest_filters.NumberFilter(label='Status')
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active') active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
@ -32,22 +41,18 @@ class BuildFilter(rest_filters.FilterSet):
def filter_active(self, queryset, name, value): def filter_active(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are active.""" """Filter the queryset to either include or exclude orders which are active."""
if str2bool(value): if str2bool(value):
queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES) return queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
else: else:
queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES) return queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
return queryset
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue') overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
def filter_overdue(self, queryset, name, value): def filter_overdue(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are overdue.""" """Filter the queryset to either include or exclude orders which are overdue."""
if str2bool(value): if str2bool(value):
queryset = queryset.filter(Build.OVERDUE_FILTER) return queryset.filter(Build.OVERDUE_FILTER)
else: else:
queryset = queryset.exclude(Build.OVERDUE_FILTER) return queryset.exclude(Build.OVERDUE_FILTER)
return queryset
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me') assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
@ -59,11 +64,21 @@ class BuildFilter(rest_filters.FilterSet):
owners = Owner.get_owners_matching_user(self.request.user) owners = Owner.get_owners_matching_user(self.request.user)
if value: if value:
queryset = queryset.filter(responsible__in=owners) return queryset.filter(responsible__in=owners)
else: else:
queryset = queryset.exclude(responsible__in=owners) return queryset.exclude(responsible__in=owners)
return queryset assigned_to = rest_filters.NumberFilter(label='responsible', method='filter_responsible')
def filter_responsible(self, queryset, name, value):
"""Filter by orders which are assigned to the specified owner."""
owners = list(Owner.objects.filter(pk=value))
# if we query by a user, also find all ownerships through group memberships
if len(owners) > 0 and owners[0].label() == 'user':
owners = Owner.get_owners_matching_user(User.objects.get(pk=owners[0].owner_id))
return queryset.filter(responsible__in=owners)
# Exact match for reference # Exact match for reference
reference = rest_filters.CharFilter( reference = rest_filters.CharFilter(
@ -84,11 +99,7 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
serializer_class = build.serializers.BuildSerializer serializer_class = build.serializers.BuildSerializer
filterset_class = BuildFilter filterset_class = BuildFilter
filter_backends = [ filter_backends = SEARCH_ORDER_FILTER_ALIAS
DjangoFilterBackend,
filters.SearchFilter,
InvenTreeOrderingFilter,
]
ordering_fields = [ ordering_fields = [
'reference', 'reference',
@ -157,18 +168,6 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
except (ValueError, Build.DoesNotExist): except (ValueError, Build.DoesNotExist):
pass pass
# Filter by "parent"
parent = params.get('parent', None)
if parent is not None:
queryset = queryset.filter(parent=parent)
# Filter by sales_order
sales_order = params.get('sales_order', None)
if sales_order is not None:
queryset = queryset.filter(sales_order=sales_order)
# Filter by "ancestor" builds # Filter by "ancestor" builds
ancestor = params.get('ancestor', None) ancestor = params.get('ancestor', None)
@ -185,12 +184,6 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
except (ValueError, Build.DoesNotExist): except (ValueError, Build.DoesNotExist):
pass pass
# Filter by associated part?
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(part=part)
# Filter by 'date range' # Filter by 'date range'
min_date = params.get('min_date', None) min_date = params.get('min_date', None)
max_date = params.get('max_date', None) max_date = params.get('max_date', None)
@ -359,6 +352,34 @@ class BuildItemDetail(RetrieveUpdateDestroyAPI):
serializer_class = build.serializers.BuildItemSerializer serializer_class = build.serializers.BuildItemSerializer
class BuildItemFilter(rest_filters.FilterSet):
"""Custom filterset for the BuildItemList API endpoint"""
class Meta:
"""Metaclass option"""
model = BuildItem
fields = [
'build',
'stock_item',
'bom_item',
'install_into',
]
part = rest_filters.ModelChoiceFilter(
queryset=part.models.Part.objects.all(),
field_name='stock_item__part',
)
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
def filter_tracked(self, queryset, name, value):
"""Filter the queryset based on whether build items are tracked"""
if str2bool(value):
return queryset.exclude(install_into=None)
else:
return queryset.filter(install_into=None)
class BuildItemList(ListCreateAPI): class BuildItemList(ListCreateAPI):
"""API endpoint for accessing a list of BuildItem objects. """API endpoint for accessing a list of BuildItem objects.
@ -367,6 +388,7 @@ class BuildItemList(ListCreateAPI):
""" """
serializer_class = build.serializers.BuildItemSerializer serializer_class = build.serializers.BuildItemSerializer
filterset_class = BuildItemFilter
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
"""Returns a BuildItemSerializer instance based on the request.""" """Returns a BuildItemSerializer instance based on the request."""
@ -404,24 +426,6 @@ class BuildItemList(ListCreateAPI):
params = self.request.query_params params = self.request.query_params
# Does the user wish to filter by part?
part_pk = params.get('part', None)
if part_pk:
queryset = queryset.filter(stock_item__part=part_pk)
# Filter by "tracked" status
# Tracked means that the item is "installed" into a build output (stock item)
tracked = params.get('tracked', None)
if tracked is not None:
tracked = str2bool(tracked)
if tracked:
queryset = queryset.exclude(install_into=None)
else:
queryset = queryset.filter(install_into=None)
# Filter by output target # Filter by output target
output = params.get('output', None) output = params.get('output', None)
@ -438,13 +442,6 @@ class BuildItemList(ListCreateAPI):
DjangoFilterBackend, DjangoFilterBackend,
] ]
filterset_fields = [
'build',
'stock_item',
'bom_item',
'install_into',
]
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects.""" """API endpoint for listing (and creating) BuildOrderAttachment objects."""
@ -472,18 +469,21 @@ build_api_urls = [
# Attachments # Attachments
re_path(r'^attachment/', include([ re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'), path(r'<int:pk>/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])), ])),
# Build Items # Build Items
re_path(r'^item/', include([ re_path(r'^item/', include([
re_path(r'^(?P<pk>\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'), path(r'<int:pk>/', include([
re_path(r'^metadata/', MetadataView.as_view(), {'model': BuildItem}, name='api-build-item-metadata'),
re_path(r'^.*$', BuildItemDetail.as_view(), name='api-build-item-detail'),
])),
re_path(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'), re_path(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'),
])), ])),
# Build Detail # Build Detail
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), re_path(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
re_path(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'), re_path(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
@ -492,9 +492,13 @@ build_api_urls = [
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'), re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'), re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
re_path(r'^metadata/', MetadataView.as_view(), {'model': Build}, name='api-build-metadata'),
re_path(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), re_path(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])), ])),
# Build order status code information
re_path(r'status/', StatusView.as_view(), {StatusView.MODEL_REF: BuildStatus}, name='api-build-status-codes'),
# Build List # Build List
re_path(r'^.*$', BuildList.as_view(), name='api-build-list'), re_path(r'^.*$', BuildList.as_view(), name='api-build-list'),
] ]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('build', '0038_alter_build_responsible'),
]
operations = [
migrations.AddField(
model_name='build',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='builditem',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-04-04 13:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('build', '0039_auto_20230317_0816'),
]
operations = [
migrations.AddField(
model_name='build',
name='barcode_data',
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
),
migrations.AddField(
model_name='build',
name='barcode_hash',
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-04-12 17:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('build', '0040_auto_20230404_1310'),
]
operations = [
migrations.AlterField(
model_name='build',
name='title',
field=models.CharField(blank=True, help_text='Brief description of the build (optional)', max_length=100, verbose_name='Description'),
),
]

View File

@ -23,7 +23,7 @@ from rest_framework import serializers
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, normalize, notify_responsible from InvenTree.helpers import increment, normalize, notify_responsible
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.models import InvenTreeAttachment, InvenTreeBarcodeMixin, ReferenceIndexingMixin
from build.validators import generate_next_build_reference, validate_build_order_reference from build.validators import generate_next_build_reference, validate_build_order_reference
@ -33,14 +33,16 @@ import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
from plugin.events import trigger_event from plugin.events import trigger_event
from plugin.models import MetadataMixin
import common.notifications import common.notifications
from part import models as PartModels
from stock import models as StockModels import part.models
from users import models as UserModels import stock.models
import users.models
class Build(MPTTModel, ReferenceIndexingMixin): class Build(MPTTModel, InvenTreeBarcodeMixin, MetadataMixin, ReferenceIndexingMixin):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects. """A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes: Attributes:
@ -64,6 +66,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
priority: Priority of the build priority: Priority of the build
""" """
class Meta:
"""Metaclass options for the BuildOrder model"""
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=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
# Global setting for specifying reference pattern # Global setting for specifying reference pattern
@ -106,11 +113,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
'parent': _('Invalid choice for parent build'), 'parent': _('Invalid choice for parent build'),
}) })
class Meta:
"""Metaclass options for the BuildOrder model"""
verbose_name = _("Build Order")
verbose_name_plural = _("Build Orders")
@staticmethod @staticmethod
def filterByDate(queryset, min_date, max_date): def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'. """Filter by 'minimum and maximum date range'.
@ -162,9 +164,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
title = models.CharField( title = models.CharField(
verbose_name=_('Description'), verbose_name=_('Description'),
blank=False, blank=True,
max_length=100, max_length=100,
help_text=_('Brief description of the build') help_text=_('Brief description of the build (optional)')
) )
parent = TreeForeignKey( parent = TreeForeignKey(
@ -278,7 +280,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
) )
responsible = models.ForeignKey( responsible = models.ForeignKey(
UserModels.Owner, users.models.Owner,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
verbose_name=_('Responsible'), verbose_name=_('Responsible'),
@ -394,9 +396,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
if in_stock is not None: if in_stock is not None:
if in_stock: if in_stock:
outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER) outputs = outputs.filter(stock.models.StockItem.IN_STOCK_FILTER)
else: else:
outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER) outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER)
# Filter by 'complete' status # Filter by 'complete' status
complete = kwargs.get('complete', None) complete = kwargs.get('complete', None)
@ -658,7 +660,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
else: else:
serial = None serial = None
output = StockModels.StockItem.objects.create( output = stock.models.StockItem.objects.create(
quantity=1, quantity=1,
location=location, location=location,
part=self.part, part=self.part,
@ -676,11 +678,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
parts = bom_item.get_valid_parts_for_allocation() parts = bom_item.get_valid_parts_for_allocation()
items = StockModels.StockItem.objects.filter( items = stock.models.StockItem.objects.filter(
part__in=parts, part__in=parts,
serial=str(serial), serial=str(serial),
quantity=1, quantity=1,
).filter(StockModels.StockItem.IN_STOCK_FILTER) ).filter(stock.models.StockItem.IN_STOCK_FILTER)
""" """
Test if there is a matching serial number! Test if there is a matching serial number!
@ -700,7 +702,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
else: else:
"""Create a single build output of the given quantity.""" """Create a single build output of the given quantity."""
StockModels.StockItem.objects.create( stock.models.StockItem.objects.create(
quantity=quantity, quantity=quantity,
location=location, location=location,
part=self.part, part=self.part,
@ -876,7 +878,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
) )
# Look for available stock items # Look for available stock items
available_stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER) available_stock = stock.models.StockItem.objects.filter(stock.models.StockItem.IN_STOCK_FILTER)
# Filter by list of available parts # Filter by list of available parts
available_stock = available_stock.filter( available_stock = available_stock.filter(
@ -1140,7 +1142,7 @@ class BuildOrderAttachment(InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments') build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildItem(models.Model): class BuildItem(MetadataMixin, models.Model):
"""A BuildItem links multiple StockItem objects to a Build. """A BuildItem links multiple StockItem objects to a Build.
These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed. These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed.
@ -1153,17 +1155,17 @@ class BuildItem(models.Model):
install_into: Destination stock item (or None) install_into: Destination stock item (or None)
""" """
@staticmethod
def get_api_url():
"""Return the API URL used to access this model"""
return reverse('api-build-item-list')
class Meta: class Meta:
"""Serializer metaclass""" """Serializer metaclass"""
unique_together = [ unique_together = [
('build', 'stock_item', 'install_into'), ('build', 'stock_item', 'install_into'),
] ]
@staticmethod
def get_api_url():
"""Return the API URL used to access this model"""
return reverse('api-build-item-list')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Custom save method for the BuildItem model""" """Custom save method for the BuildItem model"""
self.clean() self.clean()
@ -1219,7 +1221,7 @@ class BuildItem(models.Model):
'quantity': _('Quantity must be 1 for serialized stock') 'quantity': _('Quantity must be 1 for serialized stock')
}) })
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist): except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist):
pass pass
""" """
@ -1258,8 +1260,8 @@ class BuildItem(models.Model):
for idx, ancestor in enumerate(ancestors): for idx, ancestor in enumerate(ancestors):
try: try:
bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor) bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
except PartModels.BomItem.DoesNotExist: except part.models.BomItem.DoesNotExist:
continue continue
# A matching BOM item has been found! # A matching BOM item has been found!
@ -1349,7 +1351,7 @@ class BuildItem(models.Model):
# Internal model which links part <-> sub_part # Internal model which links part <-> sub_part
# We need to track this separately, to allow for "variant' stock # We need to track this separately, to allow for "variant' stock
bom_item = models.ForeignKey( bom_item = models.ForeignKey(
PartModels.BomItem, part.models.BomItem,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='allocate_build_items', related_name='allocate_build_items',
blank=True, null=True, blank=True, null=True,

View File

@ -30,7 +30,49 @@ from .models import Build, BuildItem, BuildOrderAttachment
class BuildSerializer(InvenTreeModelSerializer): class BuildSerializer(InvenTreeModelSerializer):
"""Serializes a Build object.""" """Serializes a Build object."""
class Meta:
"""Serializer metaclass"""
model = Build
fields = [
'pk',
'url',
'title',
'barcode_hash',
'batch',
'creation_date',
'completed',
'completion_date',
'destination',
'parent',
'part',
'part_detail',
'overdue',
'reference',
'sales_order',
'quantity',
'status',
'status_text',
'target_date',
'take_from',
'notes',
'link',
'issued_by',
'issued_by_detail',
'responsible',
'responsible_detail',
'priority',
]
read_only_fields = [
'completed',
'creation_date',
'completion_data',
'status',
'status_text',
]
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True) status_text = serializers.CharField(source='get_status_display', read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
@ -43,6 +85,8 @@ class BuildSerializer(InvenTreeModelSerializer):
responsible_detail = OwnerSerializer(source='responsible', read_only=True) responsible_detail = OwnerSerializer(source='responsible', read_only=True)
barcode_hash = serializers.CharField(read_only=True)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible. """Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
@ -83,46 +127,6 @@ class BuildSerializer(InvenTreeModelSerializer):
return reference return reference
class Meta:
"""Serializer metaclass"""
model = Build
fields = [
'pk',
'url',
'title',
'batch',
'creation_date',
'completed',
'completion_date',
'destination',
'parent',
'part',
'part_detail',
'overdue',
'reference',
'sales_order',
'quantity',
'status',
'status_text',
'target_date',
'take_from',
'notes',
'link',
'issued_by',
'issued_by_detail',
'responsible',
'responsible_detail',
'priority',
]
read_only_fields = [
'completed',
'creation_date',
'completion_data',
'status',
'status_text',
]
class BuildOutputSerializer(serializers.Serializer): class BuildOutputSerializer(serializers.Serializer):
"""Serializer for a "BuildOutput". """Serializer for a "BuildOutput".
@ -130,6 +134,12 @@ class BuildOutputSerializer(serializers.Serializer):
Note that a "BuildOutput" is really just a StockItem which is "in production"! Note that a "BuildOutput" is really just a StockItem which is "in production"!
""" """
class Meta:
"""Serializer metaclass"""
fields = [
'output',
]
output = serializers.PrimaryKeyRelatedField( output = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(), queryset=StockItem.objects.all(),
many=False, many=False,
@ -170,12 +180,6 @@ class BuildOutputSerializer(serializers.Serializer):
return output return output
class Meta:
"""Serializer metaclass"""
fields = [
'output',
]
class BuildOutputCreateSerializer(serializers.Serializer): class BuildOutputCreateSerializer(serializers.Serializer):
"""Serializer for creating a new BuildOutput against a BuildOrder. """Serializer for creating a new BuildOutput against a BuildOrder.
@ -633,6 +637,15 @@ class BuildUnallocationSerializer(serializers.Serializer):
class BuildAllocationItemSerializer(serializers.Serializer): class BuildAllocationItemSerializer(serializers.Serializer):
"""A serializer for allocating a single stock item against a build order.""" """A serializer for allocating a single stock item against a build order."""
class Meta:
"""Serializer metaclass"""
fields = [
'bom_item',
'stock_item',
'quantity',
'output',
]
bom_item = serializers.PrimaryKeyRelatedField( bom_item = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(), queryset=BomItem.objects.all(),
many=False, many=False,
@ -693,15 +706,6 @@ class BuildAllocationItemSerializer(serializers.Serializer):
label=_('Build Output'), label=_('Build Output'),
) )
class Meta:
"""Serializer metaclass"""
fields = [
'bom_item',
'stock_item',
'quantity',
'output',
]
def validate(self, data): def validate(self, data):
"""Perform data validation for this item""" """Perform data validation for this item"""
super().validate(data) super().validate(data)
@ -751,14 +755,14 @@ class BuildAllocationItemSerializer(serializers.Serializer):
class BuildAllocationSerializer(serializers.Serializer): class BuildAllocationSerializer(serializers.Serializer):
"""DRF serializer for allocation stock items against a build order.""" """DRF serializer for allocation stock items against a build order."""
items = BuildAllocationItemSerializer(many=True)
class Meta: class Meta:
"""Serializer metaclass""" """Serializer metaclass"""
fields = [ fields = [
'items', 'items',
] ]
items = BuildAllocationItemSerializer(many=True)
def validate(self, data): def validate(self, data):
"""Validation.""" """Validation."""
data = super().validate(data) data = super().validate(data)
@ -870,6 +874,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
class BuildItemSerializer(InvenTreeModelSerializer): class BuildItemSerializer(InvenTreeModelSerializer):
"""Serializes a BuildItem object.""" """Serializes a BuildItem object."""
class Meta:
"""Serializer metaclass"""
model = BuildItem
fields = [
'pk',
'bom_part',
'build',
'build_detail',
'install_into',
'location',
'location_detail',
'part',
'part_detail',
'stock_item',
'stock_item_detail',
'quantity'
]
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True) bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True) part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True) location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
@ -903,24 +925,6 @@ class BuildItemSerializer(InvenTreeModelSerializer):
if not stock_detail: if not stock_detail:
self.fields.pop('stock_item_detail') self.fields.pop('stock_item_detail')
class Meta:
"""Serializer metaclass"""
model = BuildItem
fields = [
'pk',
'bom_part',
'build',
'build_detail',
'install_into',
'location',
'location_detail',
'part',
'part_detail',
'stock_item',
'stock_item_detail',
'quantity'
]
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer): class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for a BuildAttachment.""" """Serializer for a BuildAttachment."""
@ -929,18 +933,6 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer metaclass""" """Serializer metaclass"""
model = BuildOrderAttachment model = BuildOrderAttachment
fields = [ fields = InvenTreeAttachmentSerializer.attachment_fields([
'pk',
'build', 'build',
'attachment', ])
'link',
'filename',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [
'upload_date',
]

View File

@ -33,6 +33,24 @@ src="{% static 'img/blank_image.png' %}"
{% url 'admin:build_build_change' build.pk as url %} {% url 'admin:build_build_change' build.pk as url %}
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
<span class='fas fa-qrcode'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% if roles.build.change %}
{% if order.barcode_hash %}
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
{% else %}
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
{% endif %}
{% endif %}
</ul>
</div>
{% endif %}
<!-- Printing options --> <!-- Printing options -->
{% if report_enabled %} {% if report_enabled %}
<div class='btn-group'> <div class='btn-group'>
@ -90,6 +108,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{% trans "Build Description" %}</td> <td>{% trans "Build Description" %}</td>
<td>{{ build.title }}</td> <td>{{ build.title }}</td>
</tr> </tr>
{% include "barcode_data.html" with instance=build %}
</table> </table>
<div class='info-messages'> <div class='info-messages'>
@ -98,13 +117,6 @@ src="{% static 'img/blank_image.png' %}"
{% trans "No build outputs have been created for this build order" %}<br> {% trans "No build outputs have been created for this build order" %}<br>
</div> </div>
{% endif %} {% endif %}
{% if build.sales_order %}
<div class='alert alert-block alert-info'>
{% object_link 'so-detail' build.sales_order.id build.sales_order as link %}
{% blocktrans %}This Build Order is allocated to Sales Order {{link}}{% endblocktrans %}
</div>
{% endif %}
{% if build.parent %} {% if build.parent %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% object_link 'build-detail' build.parent.id build.parent as link %} {% object_link 'build-detail' build.parent.id build.parent as link %}
@ -162,7 +174,12 @@ src="{% static 'img/blank_image.png' %}"
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<td><span class='fas fa-check-circle'></span></td> <td>
{% if build.completed >= build.quantity %}
<span class='fas fa-check-circle icon-green'></span>
{% else %}
<span class='fa fa-times-circle icon-red'></span>
{% endif %}
<td>{% trans "Completed" %}</td> <td>{% trans "Completed" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td> <td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td>
</tr> </tr>
@ -247,7 +264,11 @@ src="{% static 'img/blank_image.png' %}"
{% if report_enabled %} {% if report_enabled %}
$('#print-build-report').click(function() { $('#print-build-report').click(function() {
printBuildReports([{{ build.pk }}]); printReports({
items: [{{ build.pk }}],
key: 'build',
url: '{% url "api-build-report-list" %}',
});
}); });
{% endif %} {% endif %}
@ -262,4 +283,33 @@ src="{% static 'img/blank_image.png' %}"
); );
}); });
{% if barcodes %}
<!-- Barcode functionality callbacks -->
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Build Order QR Code" %}',
'{"build": {{ build.pk }}}'
);
});
{% if roles.purchase_order.change %}
$("#barcode-link").click(function() {
linkBarcodeDialog(
{
build: {{ build.pk }},
},
{
title: '{% trans "Link Barcode to Build Order" %}',
}
);
});
$("#barcode-unlink").click(function() {
unlinkBarcode({
build: {{ build.pk }},
});
});
{% endif %}
{% endif %}
{% endblock %} {% endblock %}

View File

@ -67,7 +67,7 @@
<td>{% trans "Completed" %}</td> <td>{% trans "Completed" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td> <td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td>
</tr> </tr>
{% if build.active and build.has_untracked_bom_items %} {% if build.active and has_untracked_bom_items %}
<tr> <tr>
<td><span class='fas fa-list'></span></td> <td><span class='fas fa-list'></span></td>
<td>{% trans "Allocated Parts" %}</td> <td>{% trans "Allocated Parts" %}</td>
@ -179,7 +179,7 @@
<h4>{% trans "Allocate Stock to Build" %}</h4> <h4>{% trans "Allocate Stock to Build" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.build.add and build.active and build.has_untracked_bom_items %} {% 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" %}'> <button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %} <span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
</button> </button>
@ -199,7 +199,7 @@
</div> </div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% if build.has_untracked_bom_items %} {% if has_untracked_bom_items %}
{% if build.active %} {% if build.active %}
{% if build.are_untracked_parts_allocated %} {% if build.are_untracked_parts_allocated %}
<div class='alert alert-block alert-success'> <div class='alert alert-block alert-success'>
@ -268,24 +268,6 @@
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
<!-- Label Printing Actions -->
<div class='btn-group'>
<button id='output-print-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='incomplete-output-print-label' title='{% trans "Print labels" %}'>
<span class='fas fa-tags'></span> {% trans "Print labels" %}
</a></li>
</ul>
</div>
{% if build.has_tracked_bom_items %}
{% include "expand_rows.html" with label="outputs" %}
{% include "collapse_rows.html" with label="outputs" %}
{% endif %}
{% include "filter_list.html" with id='incompletebuilditems' %} {% include "filter_list.html" with id='incompletebuilditems' %}
</div> </div>
{% endif %} {% endif %}
@ -372,20 +354,6 @@ onPanelLoad('children', function() {
onPanelLoad('attachments', function() { onPanelLoad('attachments', function() {
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-build-attachment-list" %}',
{
data: {
build: {{ build.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
$('#attachment-table').bootstrapTable('refresh');
}
}
);
loadAttachmentTable('{% url "api-build-attachment-list" %}', { loadAttachmentTable('{% url "api-build-attachment-list" %}', {
filters: { filters: {
build: {{ build.pk }}, build: {{ build.pk }},
@ -414,10 +382,6 @@ onPanelLoad('notes', function() {
); );
}); });
function reloadTable() {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
onPanelLoad('outputs', function() { onPanelLoad('outputs', function() {
{% if build.active %} {% if build.active %}
@ -436,7 +400,7 @@ onPanelLoad('outputs', function() {
{% endif %} {% endif %}
}); });
{% if build.active and build.has_untracked_bom_items %} {% if build.active and has_untracked_bom_items %}
function loadUntrackedStockTable() { function loadUntrackedStockTable() {

View File

@ -26,20 +26,6 @@
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if report_enabled %}
<div class='btn-group' role='group'>
<!-- Print actions -->
<button id='build-print-options' class='btn btn-primary dropdown-toggle' data-bs-toggle='dropdown'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='multi-build-print' title='{% trans "Print Build Orders" %}'>
<span class='fas fa-file-pdf'></span> {% trans "Print Build Orders" %}
</a></li>
</ul>
</div>
{% endif %}
{% include "filter_list.html" with id="build" %} {% include "filter_list.html" with id="build" %}
</div> </div>
</div> </div>
@ -62,17 +48,4 @@ loadBuildTable($("#build-table"), {
locale: '{{ request.LANGUAGE_CODE }}', locale: '{{ request.LANGUAGE_CODE }}',
}); });
{% if report_enabled %}
$('#multi-build-print').click(function() {
var rows = getTableData("#build-table");
var build_ids = [];
rows.forEach(function(row) {
build_ids.push(row.pk);
});
printBuildReports(build_ids);
});
{% endif %}
{% endblock %} {% endblock %}

View File

@ -37,49 +37,50 @@ class TestBuildAPI(InvenTreeAPITestCase):
def test_get_build_list(self): def test_get_build_list(self):
"""Test that we can retrieve list of build objects.""" """Test that we can retrieve list of build objects."""
url = reverse('api-build-list') url = reverse('api-build-list')
response = self.client.get(url, format='json')
response = self.get(url, expected_code=200)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 5) self.assertEqual(len(response.data), 5)
# Filter query by build status # Filter query by build status
response = self.client.get(url, {'status': 40}, format='json') response = self.get(url, {'status': 40}, expected_code=200)
self.assertEqual(len(response.data), 4) self.assertEqual(len(response.data), 4)
# Filter by "active" status # Filter by "active" status
response = self.client.get(url, {'active': True}, format='json') response = self.get(url, {'active': True}, expected_code=200)
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], 1) self.assertEqual(response.data[0]['pk'], 1)
response = self.client.get(url, {'active': False}, format='json') response = self.get(url, {'active': False}, expected_code=200)
self.assertEqual(len(response.data), 4) self.assertEqual(len(response.data), 4)
# Filter by 'part' status # Filter by 'part' status
response = self.client.get(url, {'part': 25}, format='json') response = self.get(url, {'part': 25}, expected_code=200)
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 1)
# Filter by an invalid part # Filter by an invalid part
response = self.client.get(url, {'part': 99999}, format='json') response = self.get(url, {'part': 99999}, expected_code=400)
self.assertEqual(len(response.data), 0) self.assertIn('Select a valid choice', str(response.data))
# Get a certain reference # Get a certain reference
response = self.client.get(url, {'reference': 'BO-0001'}, format='json') response = self.get(url, {'reference': 'BO-0001'}, expected_code=200)
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 1)
# Get a certain reference # Get a certain reference
response = self.client.get(url, {'reference': 'BO-9999XX'}, format='json') response = self.get(url, {'reference': 'BO-9999XX'}, expected_code=200)
self.assertEqual(len(response.data), 0) self.assertEqual(len(response.data), 0)
def test_get_build_item_list(self): def test_get_build_item_list(self):
"""Test that we can retrieve list of BuildItem objects.""" """Test that we can retrieve list of BuildItem objects."""
url = reverse('api-build-item-list') url = reverse('api-build-item-list')
response = self.client.get(url, format='json') response = self.get(url, expected_code=200)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
# Test again, filtering by park ID # Test again, filtering by park ID
response = self.client.get(url, {'part': '1'}, format='json') response = self.get(url, {'part': '1'}, expected_code=200)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -731,38 +732,42 @@ class BuildOverallocationTest(BuildAPITest):
Using same Build ID=1 as allocation test above. Using same Build ID=1 as allocation test above.
""" """
def setUp(self): @classmethod
def setUpTestData(cls):
"""Basic operation as part of test suite setup""" """Basic operation as part of test suite setup"""
super().setUp() super().setUpTestData()
self.assignRole('build.add') cls.assignRole('build.add')
self.assignRole('build.change') cls.assignRole('build.change')
self.build = Build.objects.get(pk=1) cls.build = Build.objects.get(pk=1)
self.url = reverse('api-build-finish', kwargs={'pk': self.build.pk}) cls.url = reverse('api-build-finish', kwargs={'pk': cls.build.pk})
StockItem.objects.create(part=Part.objects.get(pk=50), quantity=30) StockItem.objects.create(part=Part.objects.get(pk=50), quantity=30)
# Keep some state for use in later assertions, and then overallocate # Keep some state for use in later assertions, and then overallocate
self.state = {} cls.state = {}
self.allocation = {} cls.allocation = {}
for i, bi in enumerate(self.build.part.bom_items.all()):
rq = self.build.required_quantity(bi, None) + i + 1 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() si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first()
self.state[bi.sub_part] = (si, si.quantity, rq) cls.state[bi.sub_part] = (si, si.quantity, rq)
BuildItem.objects.create( BuildItem.objects.create(
build=self.build, build=cls.build,
stock_item=si, stock_item=si,
quantity=rq, quantity=rq,
) )
# create and complete outputs # create and complete outputs
self.build.create_build_output(self.build.quantity) cls.build.create_build_output(cls.build.quantity)
outputs = self.build.build_outputs.all() outputs = cls.build.build_outputs.all()
self.build.complete_build_output(outputs[0], self.user) cls.build.complete_build_output(outputs[0], cls.user)
def test_setup(self):
"""Validate expected state after set-up."""
# Validate expected state after set-up.
self.assertEqual(self.build.incomplete_outputs.count(), 0) self.assertEqual(self.build.incomplete_outputs.count(), 0)
self.assertEqual(self.build.complete_outputs.count(), 1) self.assertEqual(self.build.complete_outputs.count(), 1)
self.assertEqual(self.build.completed, self.build.quantity) self.assertEqual(self.build.completed, self.build.quantity)

View File

@ -29,7 +29,8 @@ class BuildTestBase(TestCase):
'users', 'users',
] ]
def setUp(self): @classmethod
def setUpTestData(cls):
"""Initialize data to use for these tests. """Initialize data to use for these tests.
The base Part 'assembly' has a BOM consisting of three parts: The base Part 'assembly' has a BOM consisting of three parts:
@ -45,27 +46,29 @@ class BuildTestBase(TestCase):
""" """
super().setUpTestData()
# Create a base "Part" # Create a base "Part"
self.assembly = Part.objects.create( cls.assembly = Part.objects.create(
name="An assembled part", name="An assembled part",
description="Why does it matter what my description is?", description="Why does it matter what my description is?",
assembly=True, assembly=True,
trackable=True, trackable=True,
) )
self.sub_part_1 = Part.objects.create( cls.sub_part_1 = Part.objects.create(
name="Widget A", name="Widget A",
description="A widget", description="A widget",
component=True component=True
) )
self.sub_part_2 = Part.objects.create( cls.sub_part_2 = Part.objects.create(
name="Widget B", name="Widget B",
description="A widget", description="A widget",
component=True component=True
) )
self.sub_part_3 = Part.objects.create( cls.sub_part_3 = Part.objects.create(
name="Widget C", name="Widget C",
description="A widget", description="A widget",
component=True, component=True,
@ -73,63 +76,63 @@ class BuildTestBase(TestCase):
) )
# Create BOM item links for the parts # Create BOM item links for the parts
self.bom_item_1 = BomItem.objects.create( cls.bom_item_1 = BomItem.objects.create(
part=self.assembly, part=cls.assembly,
sub_part=self.sub_part_1, sub_part=cls.sub_part_1,
quantity=5 quantity=5
) )
self.bom_item_2 = BomItem.objects.create( cls.bom_item_2 = BomItem.objects.create(
part=self.assembly, part=cls.assembly,
sub_part=self.sub_part_2, sub_part=cls.sub_part_2,
quantity=3, quantity=3,
optional=True optional=True
) )
# sub_part_3 is trackable! # sub_part_3 is trackable!
self.bom_item_3 = BomItem.objects.create( cls.bom_item_3 = BomItem.objects.create(
part=self.assembly, part=cls.assembly,
sub_part=self.sub_part_3, sub_part=cls.sub_part_3,
quantity=2 quantity=2
) )
ref = generate_next_build_reference() ref = generate_next_build_reference()
# Create a "Build" object to make 10x objects # Create a "Build" object to make 10x objects
self.build = Build.objects.create( cls.build = Build.objects.create(
reference=ref, reference=ref,
title="This is a build", title="This is a build",
part=self.assembly, part=cls.assembly,
quantity=10, quantity=10,
issued_by=get_user_model().objects.get(pk=1), issued_by=get_user_model().objects.get(pk=1),
) )
# Create some build output (StockItem) objects # Create some build output (StockItem) objects
self.output_1 = StockItem.objects.create( cls.output_1 = StockItem.objects.create(
part=self.assembly, part=cls.assembly,
quantity=3, quantity=3,
is_building=True, is_building=True,
build=self.build build=cls.build
) )
self.output_2 = StockItem.objects.create( cls.output_2 = StockItem.objects.create(
part=self.assembly, part=cls.assembly,
quantity=7, quantity=7,
is_building=True, is_building=True,
build=self.build, build=cls.build,
) )
# Create some stock items to assign to the build # Create some stock items to assign to the build
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=3) cls.stock_1_1 = StockItem.objects.create(part=cls.sub_part_1, quantity=3)
self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100) cls.stock_1_2 = StockItem.objects.create(part=cls.sub_part_1, quantity=100)
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5) cls.stock_2_1 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5) cls.stock_2_2 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
self.stock_2_3 = StockItem.objects.create(part=self.sub_part_2, quantity=5) cls.stock_2_3 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
self.stock_2_4 = StockItem.objects.create(part=self.sub_part_2, quantity=5) cls.stock_2_4 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
self.stock_2_5 = StockItem.objects.create(part=self.sub_part_2, quantity=5) cls.stock_2_5 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000) cls.stock_3_1 = StockItem.objects.create(part=cls.sub_part_3, quantity=1000)
class BuildTest(BuildTestBase): class BuildTest(BuildTestBase):
@ -567,6 +570,29 @@ class BuildTest(BuildTestBase):
self.assertTrue(messages.filter(user__pk=4).exists()) self.assertTrue(messages.filter(user__pk=4).exists())
def test_metadata(self):
"""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.save()
for model in [Build, BuildItem]:
p = model.objects.first()
self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)
class AutoAllocationTests(BuildTestBase): class AutoAllocationTests(BuildTestBase):
"""Tests for auto allocating stock against a build order""" """Tests for auto allocating stock against a build order"""

View File

@ -1,13 +1,13 @@
"""URL lookup for Build app.""" """URL lookup for Build app."""
from django.urls import include, re_path from django.urls import include, path, re_path
from . import views from . import views
build_urls = [ build_urls = [
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
])), ])),

View File

@ -34,15 +34,11 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
build = self.get_object() build = self.get_object()
ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False)
ctx['BuildStatus'] = BuildStatus ctx['BuildStatus'] = BuildStatus
ctx['sub_build_count'] = build.sub_build_count()
part = build.part part = build.part
bom_items = build.bom_items
ctx['part'] = part ctx['part'] = part
ctx['bom_items'] = bom_items
ctx['has_tracked_bom_items'] = build.has_tracked_bom_items() ctx['has_tracked_bom_items'] = build.has_tracked_bom_items()
ctx['has_untracked_bom_items'] = build.has_untracked_bom_items() ctx['has_untracked_bom_items'] = build.has_untracked_bom_items()

View File

@ -7,10 +7,9 @@ from django.urls import include, path, re_path
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django_filters.rest_framework import DjangoFilterBackend
from django_q.tasks import async_task from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from rest_framework import filters, permissions, serializers from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound from rest_framework.exceptions import NotAcceptable, NotFound
from rest_framework.permissions import IsAdminUser from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
@ -20,6 +19,7 @@ import common.models
import common.serializers import common.serializers
from InvenTree.api import BulkDeleteMixin from InvenTree.api import BulkDeleteMixin
from InvenTree.config import CONFIG_LOOKUPS from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
from InvenTree.helpers import inheritors from InvenTree.helpers import inheritors
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI, from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI) RetrieveUpdateDestroyAPI)
@ -167,11 +167,7 @@ class SettingsList(ListAPI):
This is inheritted by all list views for settings. This is inheritted by all list views for settings.
""" """
filter_backends = [ filter_backends = SEARCH_ORDER_FILTER
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
ordering_fields = [ ordering_fields = [
'pk', 'pk',
@ -187,7 +183,7 @@ class SettingsList(ListAPI):
class GlobalSettingsList(SettingsList): class GlobalSettingsList(SettingsList):
"""API endpoint for accessing a list of global settings objects.""" """API endpoint for accessing a list of global settings objects."""
queryset = common.models.InvenTreeSetting.objects.all() queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
serializer_class = common.serializers.GlobalSettingsSerializer serializer_class = common.serializers.GlobalSettingsSerializer
@ -216,7 +212,7 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
""" """
lookup_field = 'key' lookup_field = 'key'
queryset = common.models.InvenTreeSetting.objects.all() queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
serializer_class = common.serializers.GlobalSettingsSerializer serializer_class = common.serializers.GlobalSettingsSerializer
def get_object(self): def get_object(self):
@ -226,7 +222,10 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
if key not in common.models.InvenTreeSetting.SETTINGS.keys(): if key not in common.models.InvenTreeSetting.SETTINGS.keys():
raise NotFound() raise NotFound()
return common.models.InvenTreeSetting.get_setting_object(key) return common.models.InvenTreeSetting.get_setting_object(
key,
cache=False, create=True
)
permission_classes = [ permission_classes = [
permissions.IsAuthenticated, permissions.IsAuthenticated,
@ -284,7 +283,11 @@ class UserSettingsDetail(RetrieveUpdateAPI):
if key not in common.models.InvenTreeUserSetting.SETTINGS.keys(): if key not in common.models.InvenTreeUserSetting.SETTINGS.keys():
raise NotFound() raise NotFound()
return common.models.InvenTreeUserSetting.get_setting_object(key, user=self.request.user) return common.models.InvenTreeUserSetting.get_setting_object(
key,
user=self.request.user,
cache=False, create=True
)
permission_classes = [ permission_classes = [
UserSettingsPermissions, UserSettingsPermissions,
@ -332,11 +335,7 @@ class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
permission_classes = [permissions.IsAuthenticated, ] permission_classes = [permissions.IsAuthenticated, ]
filter_backends = [ filter_backends = SEARCH_ORDER_FILTER
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
ordering_fields = [ ordering_fields = [
'category', 'category',
@ -401,10 +400,7 @@ class NewsFeedMixin:
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI): class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
"""List view for all news items.""" """List view for all news items."""
filter_backends = [ filter_backends = ORDER_FILTER
DjangoFilterBackend,
filters.OrderingFilter,
]
ordering_fields = [ ordering_fields = [
'published', 'published',
@ -457,7 +453,7 @@ settings_api_urls = [
# Notification settings # Notification settings
re_path(r'^notification/', include([ re_path(r'^notification/', include([
# Notification Settings Detail # Notification Settings Detail
re_path(r'^(?P<pk>\d+)/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'), path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
# Notification Settings List # Notification Settings List
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'), re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
@ -486,7 +482,7 @@ common_api_urls = [
# Notifications # Notifications
re_path(r'^notifications/', include([ re_path(r'^notifications/', include([
# Individual purchase order detail URLs # Individual purchase order detail URLs
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'), re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'),
])), ])),
# Read all # Read all
@ -498,7 +494,7 @@ common_api_urls = [
# News # News
re_path(r'^news/', include([ re_path(r'^news/', include([
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'), re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'),
])), ])),
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'), re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),

View File

@ -59,7 +59,8 @@ class FileManager:
# Reset stream position to beginning of file # Reset stream position to beginning of file
file.seek(0) file.seek(0)
else: else:
raise ValidationError(_(f'Unsupported file format: {ext.upper()}')) fmt = ext.upper()
raise ValidationError(_(f'Unsupported file format: {fmt}'))
except UnicodeEncodeError: except UnicodeEncodeError:
raise ValidationError(_('Error reading file (invalid encoding)')) raise ValidationError(_('Error reading file (invalid encoding)'))

View File

@ -46,6 +46,7 @@ import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import InvenTree.validators import InvenTree.validators
import order.validators import order.validators
from plugin import registry
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -179,6 +180,10 @@ class BaseInvenTreeSetting(models.Model):
""" """
results = cls.objects.all() results = cls.objects.all()
if exclude_hidden:
# Keys which start with an undersore are used for internal functionality
results = results.exclude(key__startswith='_')
# Optionally filter by user # Optionally filter by user
if user is not None: if user is not None:
results = results.filter(user=user) results = results.filter(user=user)
@ -383,10 +388,10 @@ class BaseInvenTreeSetting(models.Model):
if not setting: if not setting:
# Unless otherwise specified, attempt to create the setting # Unless otherwise specified, attempt to create the setting
create = kwargs.get('create', True) create = kwargs.pop('create', True)
# Prevent creation of new settings objects when importing data # Prevent creation of new settings objects when importing data
if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True): if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True, allow_shell=True):
create = False create = False
if create: if create:
@ -852,6 +857,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
even if that key does not exist. even if that key does not exist.
""" """
class Meta:
"""Meta options for InvenTreeSetting."""
verbose_name = "InvenTree Setting"
verbose_name_plural = "InvenTree Settings"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""When saving a global setting, check to see if it requires a server restart. """When saving a global setting, check to see if it requires a server restart.
@ -973,6 +984,17 @@ class InvenTreeSetting(BaseInvenTreeSetting):
] ]
}, },
'INVENTREE_UPDATE_CHECK_INTERVAL': {
'name': _('Update Check Inverval'),
'description': _('How often to check for updates (set to zero to disable)'),
'validator': [
int,
MinValueValidator(0),
],
'default': 7,
'units': _('days'),
},
'INVENTREE_BACKUP_ENABLE': { 'INVENTREE_BACKUP_ENABLE': {
'name': _('Automatic Backup'), 'name': _('Automatic Backup'),
'description': _('Enable automatic backup of database and media files'), 'description': _('Enable automatic backup of database and media files'),
@ -981,20 +1003,21 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'INVENTREE_BACKUP_DAYS': { 'INVENTREE_BACKUP_DAYS': {
'name': _('Days Between Backup'), 'name': _('Auto Backup Interval'),
'description': _('Specify number of days between automated backup events'), 'description': _('Specify number of days between automated backup events'),
'validator': [ 'validator': [
int, int,
MinValueValidator(1), MinValueValidator(1),
], ],
'default': 1, 'default': 1,
'units': _('days'),
}, },
'INVENTREE_DELETE_TASKS_DAYS': { 'INVENTREE_DELETE_TASKS_DAYS': {
'name': _('Delete Old Tasks'), 'name': _('Task Deletion Interval'),
'description': _('Background task results will be deleted after specified number of days'), 'description': _('Background task results will be deleted after specified number of days'),
'default': 30, 'default': 30,
'units': 'days', 'units': _('days'),
'validator': [ 'validator': [
int, int,
MinValueValidator(7), MinValueValidator(7),
@ -1002,10 +1025,10 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'INVENTREE_DELETE_ERRORS_DAYS': { 'INVENTREE_DELETE_ERRORS_DAYS': {
'name': _('Delete Error Logs'), 'name': _('Error Log Deletion Interval'),
'description': _('Error logs will be deleted after specified number of days'), 'description': _('Error logs will be deleted after specified number of days'),
'default': 30, 'default': 30,
'units': 'days', 'units': _('days'),
'validator': [ 'validator': [
int, int,
MinValueValidator(7) MinValueValidator(7)
@ -1013,10 +1036,10 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'INVENTREE_DELETE_NOTIFICATIONS_DAYS': { 'INVENTREE_DELETE_NOTIFICATIONS_DAYS': {
'name': _('Delete Notifications'), 'name': _('Notification Deletion Interval'),
'description': _('User notifications will be deleted after specified number of days'), 'description': _('User notifications will be deleted after specified number of days'),
'default': 30, 'default': 30,
'units': 'days', 'units': _('days'),
'validator': [ 'validator': [
int, int,
MinValueValidator(7), MinValueValidator(7),
@ -1048,6 +1071,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'PART_ENABLE_REVISION': {
'name': _('Part Revisions'),
'description': _('Enable revision field for Part'),
'validator': bool,
'default': True,
},
'PART_IPN_REGEX': { 'PART_IPN_REGEX': {
'name': _('IPN Regex'), 'name': _('IPN Regex'),
'description': _('Regular expression pattern for matching Part IPN') 'description': _('Regular expression pattern for matching Part IPN')
@ -1186,9 +1216,20 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': '', 'default': '',
}, },
'PRICING_DECIMAL_PLACES_MIN': {
'name': _('Minimum Pricing Decimal Places'),
'description': _('Minimum number of decimal places to display when rendering pricing data'),
'default': 0,
'validator': [
int,
MinValueValidator(0),
MaxValueValidator(4),
]
},
'PRICING_DECIMAL_PLACES': { 'PRICING_DECIMAL_PLACES': {
'name': _('Pricing Decimal Places'), 'name': _('Maximum Pricing Decimal Places'),
'description': _('Number of decimal places to display when rendering pricing data'), 'description': _('Maximum number of decimal places to display when rendering pricing data'),
'default': 6, 'default': 6,
'validator': [ 'validator': [
int, int,
@ -1222,7 +1263,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Stock Item Pricing Age'), 'name': _('Stock Item Pricing Age'),
'description': _('Exclude stock items older than this number of days from pricing calculations'), 'description': _('Exclude stock items older than this number of days from pricing calculations'),
'default': 0, 'default': 0,
'units': 'days', 'units': _('days'),
'validator': [ 'validator': [
int, int,
MinValueValidator(0), MinValueValidator(0),
@ -1244,7 +1285,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'PRICING_UPDATE_DAYS': { 'PRICING_UPDATE_DAYS': {
'name': _('Pricing Rebuild Time'), 'name': _('Pricing Rebuild Interval'),
'description': _('Number of days before part pricing is automatically updated'), 'description': _('Number of days before part pricing is automatically updated'),
'units': _('days'), 'units': _('days'),
'default': 30, 'default': 30,
@ -1400,6 +1441,27 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': build.validators.validate_build_order_reference_pattern, 'validator': build.validators.validate_build_order_reference_pattern,
}, },
'RETURNORDER_ENABLED': {
'name': _('Enable Return Orders'),
'description': _('Enable return order functionality in the user interface'),
'validator': bool,
'default': False,
},
'RETURNORDER_REFERENCE_PATTERN': {
'name': _('Return Order Reference Pattern'),
'description': _('Required pattern for generating Return Order reference field'),
'default': 'RMA-{ref:04d}',
'validator': order.validators.validate_return_order_reference_pattern,
},
'RETURNORDER_EDIT_COMPLETED_ORDERS': {
'name': _('Edit Completed Return Orders'),
'description': _('Allow editing of return orders after they have been completed'),
'default': False,
'validator': bool,
},
'SALESORDER_REFERENCE_PATTERN': { 'SALESORDER_REFERENCE_PATTERN': {
'name': _('Sales Order Reference Pattern'), 'name': _('Sales Order Reference Pattern'),
'description': _('Required pattern for generating Sales Order reference field'), 'description': _('Required pattern for generating Sales Order reference field'),
@ -1568,16 +1630,39 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
'requires_restart': True, 'requires_restart': True,
}, },
'STOCKTAKE_ENABLE': {
'name': _('Stocktake Functionality'),
'description': _('Enable stocktake functionality for recording stock levels and calculating stock value'),
'validator': bool,
'default': False,
},
'STOCKTAKE_AUTO_DAYS': {
'name': _('Automatic Stocktake Period'),
'description': _('Number of days between automatic stocktake recording (set to zero to disable)'),
'validator': [
int,
MinValueValidator(0),
],
'default': 0,
},
'STOCKTAKE_DELETE_REPORT_DAYS': {
'name': _('Report Deletion Interval'),
'description': _('Stocktake reports will be deleted after specified number of days'),
'default': 30,
'units': _('days'),
'validator': [
int,
MinValueValidator(7),
]
},
} }
typ = 'inventree' typ = 'inventree'
class Meta:
"""Meta options for InvenTreeSetting."""
verbose_name = "InvenTree Setting"
verbose_name_plural = "InvenTree Settings"
key = models.CharField( key = models.CharField(
max_length=50, max_length=50,
blank=False, blank=False,
@ -1599,9 +1684,27 @@ class InvenTreeSetting(BaseInvenTreeSetting):
return False return False
def label_printer_options():
"""Build a list of available label printer options."""
printers = [('', _('No Printer (Export to PDF)'))]
label_printer_plugins = registry.with_mixin('labels')
if label_printer_plugins:
printers.extend([(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins])
return printers
class InvenTreeUserSetting(BaseInvenTreeSetting): class InvenTreeUserSetting(BaseInvenTreeSetting):
"""An InvenTreeSetting object with a usercontext.""" """An InvenTreeSetting object with a usercontext."""
class Meta:
"""Meta options for InvenTreeUserSetting."""
verbose_name = "InvenTree User Setting"
verbose_name_plural = "InvenTree User Settings"
constraints = [
models.UniqueConstraint(fields=['key', 'user'], name='unique key and user')
]
SETTINGS = { SETTINGS = {
'HOMEPAGE_PART_STARRED': { 'HOMEPAGE_PART_STARRED': {
'name': _('Show subscribed parts'), 'name': _('Show subscribed parts'),
@ -1743,6 +1846,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
"LABEL_DEFAULT_PRINTER": {
'name': _('Default label printer'),
'description': _('Configure which label printer should be selected by default'),
'default': '',
'choices': label_printer_options
},
"REPORT_INLINE": { "REPORT_INLINE": {
'name': _('Inline report display'), 'name': _('Inline report display'),
'description': _('Display PDF reports in the browser, instead of downloading as a file'), 'description': _('Display PDF reports in the browser, instead of downloading as a file'),
@ -1758,7 +1868,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
}, },
'SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS': { 'SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS': {
'name': _('Seach Supplier Parts'), 'name': _('Search Supplier Parts'),
'description': _('Display supplier parts in search preview window'), 'description': _('Display supplier parts in search preview window'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
@ -1848,6 +1958,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': True, 'default': True,
}, },
'SEARCH_PREVIEW_SHOW_RETURN_ORDERS': {
'name': _('Search Return Orders'),
'description': _('Display return orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS': {
'name': _('Exclude Inactive Return Orders'),
'description': _('Exclude inactive return orders from search preview window'),
'validator': bool,
'default': True,
},
'SEARCH_PREVIEW_RESULTS': { 'SEARCH_PREVIEW_RESULTS': {
'name': _('Search Preview Results'), 'name': _('Search Preview Results'),
'description': _('Number of results to show in each section of the search preview window'), 'description': _('Number of results to show in each section of the search preview window'),
@ -1855,6 +1979,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': [int, MinValueValidator(1)] 'validator': [int, MinValueValidator(1)]
}, },
'SEARCH_REGEX': {
'name': _('Regex Search'),
'description': _('Enable regular expressions in search queries'),
'default': False,
'validator': bool,
},
'SEARCH_WHOLE': {
'name': _('Whole Word Search'),
'description': _('Search queries return results for whole word matches'),
'default': False,
'validator': bool,
},
'PART_SHOW_QUANTITY_IN_FORMS': { 'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'), 'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'), 'description': _('Display available part quantity in some forms'),
@ -1900,7 +2038,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'DISPLAY_STOCKTAKE_TAB': { 'DISPLAY_STOCKTAKE_TAB': {
'name': _('Part Stocktake'), 'name': _('Part Stocktake'),
'description': _('Display part stocktake information'), 'description': _('Display part stocktake information (if stocktake functionality is enabled)'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },
@ -1918,15 +2056,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
typ = 'user' typ = 'user'
class Meta:
"""Meta options for InvenTreeUserSetting."""
verbose_name = "InvenTree User Setting"
verbose_name_plural = "InvenTree User Settings"
constraints = [
models.UniqueConstraint(fields=['key', 'user'], name='unique key and user')
]
key = models.CharField( key = models.CharField(
max_length=50, max_length=50,
blank=False, blank=False,

View File

@ -180,7 +180,7 @@ class MethodStorageClass:
Args: Args:
selected_classes (class, optional): References to the classes that should be registered. Defaults to None. selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
""" """
logger.info('collecting notification methods') logger.debug('Collecting notification methods')
current_method = InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS current_method = InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
# for testing selective loading is made available # for testing selective loading is made available
@ -196,7 +196,7 @@ class MethodStorageClass:
filtered_list[ref] = item filtered_list[ref] = item
storage.liste = list(filtered_list.values()) storage.liste = list(filtered_list.values())
logger.info(f'found {len(storage.liste)} notification methods') logger.info(f'Found {len(storage.liste)} notification methods')
def get_usersettings(self, user) -> list: def get_usersettings(self, user) -> list:
"""Returns all user settings for a specific user. """Returns all user settings for a specific user.
@ -305,6 +305,13 @@ class InvenTreeNotificationBodies:
template='email/purchase_order_received.html', template='email/purchase_order_received.html',
) )
ReturnOrderItemsReceived = NotificationBody(
name=_('Items Received'),
slug='return_order.items_received',
message=_('Items have been received against a return order'),
template='email/return_order_received.html',
)
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
"""Send out a notification.""" """Send out a notification."""

View File

@ -77,8 +77,6 @@ class GlobalSettingsSerializer(SettingsSerializer):
class UserSettingsSerializer(SettingsSerializer): class UserSettingsSerializer(SettingsSerializer):
"""Serializer for the InvenTreeUserSetting model.""" """Serializer for the InvenTreeUserSetting model."""
user = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta: class Meta:
"""Meta options for UserSettingsSerializer.""" """Meta options for UserSettingsSerializer."""
@ -97,6 +95,8 @@ class UserSettingsSerializer(SettingsSerializer):
'typ', 'typ',
] ]
user = serializers.PrimaryKeyRelatedField(read_only=True)
class GenericReferencedSettingSerializer(SettingsSerializer): class GenericReferencedSettingSerializer(SettingsSerializer):
"""Serializer for a GenericReferencedSetting model. """Serializer for a GenericReferencedSetting model.
@ -140,28 +140,41 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
class NotificationMessageSerializer(InvenTreeModelSerializer): class NotificationMessageSerializer(InvenTreeModelSerializer):
"""Serializer for the InvenTreeUserSetting model.""" """Serializer for the InvenTreeUserSetting model."""
class Meta:
"""Meta options for NotificationMessageSerializer."""
model = NotificationMessage
fields = [
'pk',
'target',
'source',
'user',
'category',
'name',
'message',
'creation',
'age',
'age_human',
'read',
]
read_only_fields = [
'category',
'name',
'message',
'creation',
'age',
'age_human',
]
target = serializers.SerializerMethodField(read_only=True) target = serializers.SerializerMethodField(read_only=True)
source = serializers.SerializerMethodField(read_only=True) source = serializers.SerializerMethodField(read_only=True)
user = serializers.PrimaryKeyRelatedField(read_only=True) user = serializers.PrimaryKeyRelatedField(read_only=True)
category = serializers.CharField(read_only=True)
name = serializers.CharField(read_only=True)
message = serializers.CharField(read_only=True)
creation = serializers.CharField(read_only=True)
age = serializers.IntegerField(read_only=True)
age_human = serializers.CharField(read_only=True)
read = serializers.BooleanField() read = serializers.BooleanField()
def get_target(self, obj): def get_target(self, obj):
"""Function to resolve generic object reference to target.""" """Function to resolve generic object reference to target."""
target = get_objectreference(obj, 'target_content_type', 'target_object_id') target = get_objectreference(obj, 'target_content_type', 'target_object_id')
if target and 'link' not in target: if target and 'link' not in target:
@ -184,30 +197,10 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
"""Function to resolve generic object reference to source.""" """Function to resolve generic object reference to source."""
return get_objectreference(obj, 'source_content_type', 'source_object_id') return get_objectreference(obj, 'source_content_type', 'source_object_id')
class Meta:
"""Meta options for NotificationMessageSerializer."""
model = NotificationMessage
fields = [
'pk',
'target',
'source',
'user',
'category',
'name',
'message',
'creation',
'age',
'age_human',
'read',
]
class NewsFeedEntrySerializer(InvenTreeModelSerializer): class NewsFeedEntrySerializer(InvenTreeModelSerializer):
"""Serializer for the NewsFeedEntry model.""" """Serializer for the NewsFeedEntry model."""
read = serializers.BooleanField()
class Meta: class Meta:
"""Meta options for NewsFeedEntrySerializer.""" """Meta options for NewsFeedEntrySerializer."""
@ -223,6 +216,8 @@ class NewsFeedEntrySerializer(InvenTreeModelSerializer):
'read', 'read',
] ]
read = serializers.BooleanField()
class ConfigSerializer(serializers.Serializer): class ConfigSerializer(serializers.Serializer):
"""Serializer for the InvenTree configuration. """Serializer for the InvenTree configuration.

View File

@ -1,6 +1,7 @@
"""Tests for mechanisms in common.""" """Tests for mechanisms in common."""
import json import json
import time
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
@ -921,7 +922,16 @@ class CurrencyAPITests(InvenTreeAPITestCase):
# Delete any existing exchange rate data # Delete any existing exchange rate data
Rate.objects.all().delete() Rate.objects.all().delete()
self.post(reverse('api-currency-refresh')) # Updating via the external exchange may not work every time
for _idx in range(5):
self.post(reverse('api-currency-refresh'))
# There should be some new exchange rate objects now # There should be some new exchange rate objects now
self.assertTrue(Rate.objects.all().exists()) if Rate.objects.all().exists():
# Exit early
return
# Delay and try again
time.sleep(10)
raise TimeoutError("Could not refresh currency exchange data after 5 attempts")

View File

@ -41,6 +41,13 @@ class CompanyAdmin(ImportExportModelAdmin):
class SupplierPartResource(InvenTreeResource): class SupplierPartResource(InvenTreeResource):
"""Class for managing SupplierPart data import/export.""" """Class for managing SupplierPart data import/export."""
class Meta:
"""Metaclass defines extra admin options"""
model = SupplierPart
skip_unchanged = True
report_skipped = True
clean_model_instances = True
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
part_name = Field(attribute='part__full_name', readonly=True) part_name = Field(attribute='part__full_name', readonly=True)
@ -49,13 +56,6 @@ class SupplierPartResource(InvenTreeResource):
supplier_name = Field(attribute='supplier__name', readonly=True) supplier_name = Field(attribute='supplier__name', readonly=True)
class Meta:
"""Metaclass defines extra admin options"""
model = SupplierPart
skip_unchanged = True
report_skipped = True
clean_model_instances = True
class SupplierPriceBreakInline(admin.TabularInline): class SupplierPriceBreakInline(admin.TabularInline):
"""Inline for supplier-part pricing""" """Inline for supplier-part pricing"""
@ -87,6 +87,13 @@ class SupplierPartAdmin(ImportExportModelAdmin):
class ManufacturerPartResource(InvenTreeResource): class ManufacturerPartResource(InvenTreeResource):
"""Class for managing ManufacturerPart data import/export.""" """Class for managing ManufacturerPart data import/export."""
class Meta:
"""Metaclass defines extra admin options"""
model = ManufacturerPart
skip_unchanged = True
report_skipped = True
clean_model_instances = True
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
part_name = Field(attribute='part__full_name', readonly=True) part_name = Field(attribute='part__full_name', readonly=True)
@ -95,13 +102,6 @@ class ManufacturerPartResource(InvenTreeResource):
manufacturer_name = Field(attribute='manufacturer__name', readonly=True) manufacturer_name = Field(attribute='manufacturer__name', readonly=True)
class Meta:
"""Metaclass defines extra admin options"""
model = ManufacturerPart
skip_unchanged = True
report_skipped = True
clean_model_instances = True
class ManufacturerPartAdmin(ImportExportModelAdmin): class ManufacturerPartAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPart model.""" """Admin class for ManufacturerPart model."""
@ -157,6 +157,13 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
class SupplierPriceBreakResource(InvenTreeResource): class SupplierPriceBreakResource(InvenTreeResource):
"""Class for managing SupplierPriceBreak data import/export.""" """Class for managing SupplierPriceBreak data import/export."""
class Meta:
"""Metaclass defines extra admin options"""
model = SupplierPriceBreak
skip_unchanged = True
report_skipped = False
clean_model_instances = True
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
supplier_id = Field(attribute='part__supplier__pk', readonly=True) supplier_id = Field(attribute='part__supplier__pk', readonly=True)
@ -169,13 +176,6 @@ class SupplierPriceBreakResource(InvenTreeResource):
MPN = Field(attribute='part__MPN', readonly=True) MPN = Field(attribute='part__MPN', readonly=True)
class Meta:
"""Metaclass defines extra admin options"""
model = SupplierPriceBreak
skip_unchanged = True
report_skipped = False
clean_model_instances = True
class SupplierPriceBreakAdmin(ImportExportModelAdmin): class SupplierPriceBreakAdmin(ImportExportModelAdmin):
"""Admin class for the SupplierPriceBreak model""" """Admin class for the SupplierPriceBreak model"""

View File

@ -1,24 +1,24 @@
"""Provides a JSON API for the Company app.""" """Provides a JSON API for the Company app."""
from django.db.models import Q from django.db.models import Q
from django.urls import include, re_path from django.urls import include, path, re_path
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters
import part.models import part.models
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView from InvenTree.api import (AttachmentMixin, ListCreateDestroyAPIView,
from InvenTree.filters import InvenTreeOrderingFilter MetadataView)
from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS)
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI, from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
RetrieveUpdateDestroyAPI)
from plugin.serializers import MetadataSerializer
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment, from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartParameter, SupplierPart, ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPriceBreak) SupplierPart, SupplierPriceBreak)
from .serializers import (CompanySerializer, from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
ContactSerializer,
ManufacturerPartAttachmentSerializer, ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer, ManufacturerPartParameterSerializer,
ManufacturerPartSerializer, SupplierPartSerializer, ManufacturerPartSerializer, SupplierPartSerializer,
@ -44,11 +44,7 @@ class CompanyList(ListCreateAPI):
return queryset return queryset
filter_backends = [ filter_backends = SEARCH_ORDER_FILTER
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = [ filterset_fields = [
'is_customer', 'is_customer',
@ -86,14 +82,57 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
return queryset return queryset
class CompanyMetadata(RetrieveUpdateAPI): class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for viewing / updating Company metadata.""" """API endpoint for the CompanyAttachment model"""
def get_serializer(self, *args, **kwargs): queryset = CompanyAttachment.objects.all()
"""Return MetadataSerializer instance for a Company""" serializer_class = CompanyAttachmentSerializer
return MetadataSerializer(Company, *args, **kwargs)
queryset = Company.objects.all() filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [
'company',
]
class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for CompanyAttachment model."""
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
class ContactList(ListCreateDestroyAPIView):
"""API endpoint for list view of Company model"""
queryset = Contact.objects.all()
serializer_class = ContactSerializer
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [
'company',
]
search_fields = [
'company__name',
'name',
]
ordering_fields = [
'name',
]
ordering = 'name'
class ContactDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for Company model"""
queryset = Contact.objects.all()
serializer_class = ContactSerializer
class ManufacturerPartFilter(rest_filters.FilterSet): class ManufacturerPartFilter(rest_filters.FilterSet):
@ -145,11 +184,7 @@ class ManufacturerPartList(ListCreateDestroyAPIView):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
filter_backends = [ filter_backends = SEARCH_ORDER_FILTER
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
search_fields = [ search_fields = [
'manufacturer__name', 'manufacturer__name',
@ -195,11 +230,30 @@ class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI
serializer_class = ManufacturerPartAttachmentSerializer serializer_class = ManufacturerPartAttachmentSerializer
class ManufacturerPartParameterFilter(rest_filters.FilterSet):
"""Custom filterset for the ManufacturerPartParameterList API endpoint"""
class Meta:
"""Metaclass options"""
model = ManufacturerPartParameter
fields = [
'name',
'value',
'units',
'manufacturer_part',
]
manufacturer = rest_filters.ModelChoiceFilter(queryset=Company.objects.all(), field_name='manufacturer_part__manufacturer')
part = rest_filters.ModelChoiceFilter(queryset=part.models.Part.objects.all(), field_name='manufacturer_part__part')
class ManufacturerPartParameterList(ListCreateDestroyAPIView): class ManufacturerPartParameterList(ListCreateDestroyAPIView):
"""API endpoint for list view of ManufacturerPartParamater model.""" """API endpoint for list view of ManufacturerPartParamater model."""
queryset = ManufacturerPartParameter.objects.all() queryset = ManufacturerPartParameter.objects.all()
serializer_class = ManufacturerPartParameterSerializer serializer_class = ManufacturerPartParameterSerializer
filterset_class = ManufacturerPartParameterFilter
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint""" """Return serializer instance for this endpoint"""
@ -221,38 +275,7 @@ class ManufacturerPartParameterList(ListCreateDestroyAPIView):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset): filter_backends = SEARCH_ORDER_FILTER
"""Custom filtering for the queryset."""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filter by manufacturer?
manufacturer = params.get('manufacturer', None)
if manufacturer is not None:
queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer)
# Filter by part?
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(manufacturer_part__part=part)
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = [
'name',
'value',
'units',
'manufacturer_part',
]
search_fields = [ search_fields = [
'name', 'name',
@ -349,11 +372,7 @@ class SupplierPartList(ListCreateDestroyAPIView):
serializer_class = SupplierPartSerializer serializer_class = SupplierPartSerializer
filter_backends = [ filter_backends = SEARCH_ORDER_FILTER_ALIAS
DjangoFilterBackend,
filters.SearchFilter,
InvenTreeOrderingFilter,
]
ordering_fields = [ ordering_fields = [
'SKU', 'SKU',
@ -405,6 +424,15 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI):
class SupplierPriceBreakFilter(rest_filters.FilterSet): class SupplierPriceBreakFilter(rest_filters.FilterSet):
"""Custom API filters for the SupplierPriceBreak list endpoint""" """Custom API filters for the SupplierPriceBreak list endpoint"""
class Meta:
"""Metaclass options"""
model = SupplierPriceBreak
fields = [
'part',
'quantity',
]
base_part = rest_filters.ModelChoiceFilter( base_part = rest_filters.ModelChoiceFilter(
label='Base Part', label='Base Part',
queryset=part.models.Part.objects.all(), queryset=part.models.Part.objects.all(),
@ -417,15 +445,6 @@ class SupplierPriceBreakFilter(rest_filters.FilterSet):
field_name='part__supplier', field_name='part__supplier',
) )
class Meta:
"""Metaclass options"""
model = SupplierPriceBreak
fields = [
'part',
'quantity',
]
class SupplierPriceBreakList(ListCreateAPI): class SupplierPriceBreakList(ListCreateAPI):
"""API endpoint for list view of SupplierPriceBreak object. """API endpoint for list view of SupplierPriceBreak object.
@ -454,10 +473,7 @@ class SupplierPriceBreakList(ListCreateAPI):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
filter_backends = [ filter_backends = ORDER_FILTER
DjangoFilterBackend,
filters.OrderingFilter,
]
ordering_fields = [ ordering_fields = [
'quantity', 'quantity',
@ -477,18 +493,21 @@ manufacturer_part_api_urls = [
# Base URL for ManufacturerPartAttachment API endpoints # Base URL for ManufacturerPartAttachment API endpoints
re_path(r'^attachment/', include([ re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'), path(r'<int:pk>/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'), re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'),
])), ])),
re_path(r'^parameter/', include([ re_path(r'^parameter/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'), path(r'<int:pk>/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
# Catch anything else # Catch anything else
re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'), re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'),
])), ])),
re_path(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'), re_path(r'^(?P<pk>\d+)/?', include([
re_path('^metadata/', MetadataView.as_view(), {'model': ManufacturerPart}, name='api-manufacturer-part-metadata'),
re_path('^.*$', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
])),
# Catch anything else # Catch anything else
re_path(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'), re_path(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'),
@ -497,7 +516,10 @@ manufacturer_part_api_urls = [
supplier_part_api_urls = [ supplier_part_api_urls = [
re_path(r'^(?P<pk>\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), re_path(r'^(?P<pk>\d+)/?', include([
re_path('^metadata/', MetadataView.as_view(), {'model': SupplierPart}, name='api-supplier-part-metadata'),
re_path('^.*$', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
])),
# Catch anything else # Catch anything else
re_path(r'^.*$', SupplierPartList.as_view(), name='api-supplier-part-list'), re_path(r'^.*$', SupplierPartList.as_view(), name='api-supplier-part-list'),
@ -517,10 +539,20 @@ company_api_urls = [
])), ])),
re_path(r'^(?P<pk>\d+)/?', include([ re_path(r'^(?P<pk>\d+)/?', include([
re_path(r'^metadata/', CompanyMetadata.as_view(), name='api-company-metadata'), re_path(r'^metadata/', MetadataView.as_view(), {'model': Company}, name='api-company-metadata'),
re_path(r'^.*$', CompanyDetail.as_view(), name='api-company-detail'), re_path(r'^.*$', CompanyDetail.as_view(), name='api-company-detail'),
])), ])),
re_path(r'^attachment/', include([
path(r'<int:pk>/', CompanyAttachmentDetail.as_view(), name='api-company-attachment-detail'),
re_path(r'^$', CompanyAttachmentList.as_view(), name='api-company-attachment-list'),
])),
re_path(r'^contact/', include([
path('<int:pk>/', ContactDetail.as_view(), name='api-contact-detail'),
re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'),
])),
re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'), re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'),
] ]

View File

@ -23,7 +23,7 @@ def migrate_currencies(apps, schema_editor):
for the SupplierPriceBreak model, to a new django-money compatible currency. for the SupplierPriceBreak model, to a new django-money compatible currency.
""" """
logger.info("Updating currency references for SupplierPriceBreak model...") logger.debug("Updating currency references for SupplierPriceBreak model...")
# A list of available currency codes # A list of available currency codes
currency_codes = CURRENCIES.keys() currency_codes = CURRENCIES.keys()

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.16 on 2023-02-15 12:55
import InvenTree.fields
import InvenTree.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('company', '0053_supplierpart_updated'),
]
operations = [
migrations.CreateModel(
name='CompanyAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='company.company', verbose_name='Company')),
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0054_companyattachment'),
]
operations = [
migrations.AddField(
model_name='manufacturerpart',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='supplierpart',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -81,11 +81,6 @@ class Company(MetadataMixin, models.Model):
currency_code: Specifies the default currency for the company currency_code: Specifies the default currency for the company
""" """
@staticmethod
def get_api_url():
"""Return the API URL associated with the Company model"""
return reverse('api-company-list')
class Meta: class Meta:
"""Metaclass defines extra model options""" """Metaclass defines extra model options"""
ordering = ['name', ] ordering = ['name', ]
@ -94,6 +89,11 @@ class Company(MetadataMixin, models.Model):
] ]
verbose_name_plural = "Companies" verbose_name_plural = "Companies"
@staticmethod
def get_api_url():
"""Return the API URL associated with the Company model"""
return reverse('api-company-list')
name = models.CharField(max_length=100, blank=False, name = models.CharField(max_length=100, blank=False,
help_text=_('Company name'), help_text=_('Company name'),
verbose_name=_('Company name')) verbose_name=_('Company name'))
@ -205,6 +205,25 @@ class Company(MetadataMixin, models.Model):
return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).all() return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).all()
class CompanyAttachment(InvenTreeAttachment):
"""Model for storing file or URL attachments against a Company object"""
@staticmethod
def get_api_url():
"""Return the API URL associated with this model"""
return reverse('api-company-attachment-list')
def getSubdir(self):
"""Return the subdirectory where these attachments are uploaded"""
return os.path.join('company_files', str(self.company.pk))
company = models.ForeignKey(
Company, on_delete=models.CASCADE,
verbose_name=_('Company'),
related_name='attachments',
)
class Contact(models.Model): class Contact(models.Model):
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects. """A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
@ -216,6 +235,11 @@ class Contact(models.Model):
role: position in company role: position in company
""" """
@staticmethod
def get_api_url():
"""Return the API URL associated with the Contcat model"""
return reverse('api-contact-list')
company = models.ForeignKey(Company, related_name='contacts', company = models.ForeignKey(Company, related_name='contacts',
on_delete=models.CASCADE) on_delete=models.CASCADE)
@ -228,7 +252,7 @@ class Contact(models.Model):
role = models.CharField(max_length=100, blank=True) role = models.CharField(max_length=100, blank=True)
class ManufacturerPart(models.Model): class ManufacturerPart(MetadataMixin, models.Model):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers. """Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
Attributes: Attributes:
@ -239,15 +263,15 @@ class ManufacturerPart(models.Model):
description: Descriptive notes field description: Descriptive notes field
""" """
class Meta:
"""Metaclass defines extra model options"""
unique_together = ('part', 'manufacturer', 'MPN')
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
"""Return the API URL associated with the ManufacturerPart instance""" """Return the API URL associated with the ManufacturerPart instance"""
return reverse('api-manufacturer-part-list') return reverse('api-manufacturer-part-list')
class Meta:
"""Metaclass defines extra model options"""
unique_together = ('part', 'manufacturer', 'MPN')
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='manufacturer_parts', related_name='manufacturer_parts',
verbose_name=_('Base Part'), verbose_name=_('Base Part'),
@ -341,15 +365,15 @@ class ManufacturerPartParameter(models.Model):
Each parameter is a simple string (text) value. Each parameter is a simple string (text) value.
""" """
class Meta:
"""Metaclass defines extra model options"""
unique_together = ('manufacturer_part', 'name')
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
"""Return the API URL associated with the ManufacturerPartParameter model""" """Return the API URL associated with the ManufacturerPartParameter model"""
return reverse('api-manufacturer-part-parameter-list') return reverse('api-manufacturer-part-parameter-list')
class Meta:
"""Metaclass defines extra model options"""
unique_together = ('manufacturer_part', 'name')
manufacturer_part = models.ForeignKey( manufacturer_part = models.ForeignKey(
ManufacturerPart, ManufacturerPart,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -396,7 +420,7 @@ class SupplierPartManager(models.Manager):
) )
class SupplierPart(InvenTreeBarcodeMixin, common.models.MetaMixin): class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin):
"""Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers. """Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers.
Attributes: Attributes:
@ -415,6 +439,13 @@ class SupplierPart(InvenTreeBarcodeMixin, common.models.MetaMixin):
updated: Date that the SupplierPart was last updated updated: Date that the SupplierPart was last updated
""" """
class Meta:
"""Metaclass defines extra model options"""
unique_together = ('part', 'supplier', 'SKU')
# This model was moved from the 'Part' app
db_table = 'part_supplierpart'
objects = SupplierPartManager() objects = SupplierPartManager()
@staticmethod @staticmethod
@ -434,13 +465,6 @@ class SupplierPart(InvenTreeBarcodeMixin, common.models.MetaMixin):
} }
} }
class Meta:
"""Metaclass defines extra model options"""
unique_together = ('part', 'supplier', 'SKU')
# This model was moved from the 'Part' app
db_table = 'part_supplierpart'
def clean(self): def clean(self):
"""Custom clean action for the SupplierPart model: """Custom clean action for the SupplierPart model:
@ -677,13 +701,6 @@ class SupplierPriceBreak(common.models.PriceBreak):
currency: Reference to the currency of this pricebreak (leave empty for base currency) currency: Reference to the currency of this pricebreak (leave empty for base currency)
""" """
@staticmethod
def get_api_url():
"""Return the API URL associated with the SupplierPriceBreak model"""
return reverse('api-part-supplier-price-list')
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks', verbose_name=_('Part'),)
class Meta: class Meta:
"""Metaclass defines extra model options""" """Metaclass defines extra model options"""
unique_together = ("part", "quantity") unique_together = ("part", "quantity")
@ -695,6 +712,13 @@ class SupplierPriceBreak(common.models.PriceBreak):
"""Format a string representation of a SupplierPriceBreak instance""" """Format a string representation of a SupplierPriceBreak instance"""
return f'{self.part.SKU} - {self.price} @ {self.quantity}' return f'{self.part.SKU} - {self.price} @ {self.quantity}'
@staticmethod
def get_api_url():
"""Return the API URL associated with the SupplierPriceBreak model"""
return reverse('api-part-supplier-price-list')
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks', verbose_name=_('Part'),)
@receiver(post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break') @receiver(post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break')
def after_save_supplier_price(sender, instance, created, **kwargs): def after_save_supplier_price(sender, instance, created, **kwargs):
@ -703,7 +727,7 @@ def after_save_supplier_price(sender, instance, created, **kwargs):
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if instance.part and instance.part.part: if instance.part and instance.part.part:
instance.part.part.schedule_pricing_update() instance.part.part.schedule_pricing_update(create=True)
@receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break') @receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break')
@ -713,4 +737,4 @@ def after_delete_supplier_price(sender, instance, **kwargs):
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if instance.part and instance.part.part: if instance.part and instance.part.part:
instance.part.part.schedule_pricing_update() instance.part.part.schedule_pricing_update(create=False)

View File

@ -9,26 +9,22 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
import part.filters import part.filters
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (InvenTreeAttachmentSerializer, from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, RemoteImageMixin) InvenTreeMoneySerializer, RemoteImageMixin)
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment, from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartParameter, SupplierPart, ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPriceBreak) SupplierPart, SupplierPriceBreak)
class CompanyBriefSerializer(InvenTreeModelSerializer): class CompanyBriefSerializer(InvenTreeModelSerializer):
"""Serializer for Company object (limited detail)""" """Serializer for Company object (limited detail)"""
url = serializers.CharField(source='get_absolute_url', read_only=True)
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -41,39 +37,14 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
'image', 'image',
] ]
url = serializers.CharField(source='get_absolute_url', read_only=True)
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for Company object (full detail)""" """Serializer for Company object (full detail)"""
@staticmethod
def annotate_queryset(queryset):
"""Annoate the supplied queryset with aggregated information"""
# Add count of parts manufactured
queryset = queryset.annotate(
parts_manufactured=SubqueryCount('manufactured_parts')
)
queryset = queryset.annotate(
parts_supplied=SubqueryCount('supplied_parts')
)
return queryset
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)
currency = serializers.ChoiceField(
choices=currency_code_mappings(),
initial=currency_code_default,
help_text=_('Default currency used for this supplier'),
label=_('Currency Code'),
required=True,
)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -101,6 +72,29 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
'remote_image', 'remote_image',
] ]
@staticmethod
def annotate_queryset(queryset):
"""Annoate the supplied queryset with aggregated information"""
# Add count of parts manufactured
queryset = queryset.annotate(
parts_manufactured=SubqueryCount('manufactured_parts')
)
queryset = queryset.annotate(
parts_supplied=SubqueryCount('supplied_parts')
)
return queryset
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)
currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True)
def save(self): def save(self):
"""Save the Company instance""" """Save the Company instance"""
super().save() super().save()
@ -126,14 +120,53 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
return self.instance return self.instance
class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the CompanyAttachment class"""
class Meta:
"""Metaclass defines serializer options"""
model = CompanyAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields([
'company',
])
class ContactSerializer(InvenTreeModelSerializer):
"""Serializer class for the Contact model"""
class Meta:
"""Metaclass options"""
model = Contact
fields = [
'pk',
'company',
'name',
'phone',
'email',
'role',
]
class ManufacturerPartSerializer(InvenTreeModelSerializer): class ManufacturerPartSerializer(InvenTreeModelSerializer):
"""Serializer for ManufacturerPart object.""" """Serializer for ManufacturerPart object."""
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) class Meta:
"""Metaclass options."""
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True) model = ManufacturerPart
fields = [
pretty_name = serializers.CharField(read_only=True) 'pk',
'part',
'part_detail',
'pretty_name',
'manufacturer',
'manufacturer_detail',
'description',
'MPN',
'link',
]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required""" """Initialize this serializer with extra detail fields as required"""
@ -152,24 +185,14 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
if prettify is not True: if prettify is not True:
self.fields.pop('pretty_name') self.fields.pop('pretty_name')
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True)
pretty_name = serializers.CharField(read_only=True)
manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True)) manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True))
class Meta:
"""Metaclass options."""
model = ManufacturerPart
fields = [
'pk',
'part',
'part_detail',
'pretty_name',
'manufacturer',
'manufacturer_detail',
'description',
'MPN',
'link',
]
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer): class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the ManufacturerPartAttachment class.""" """Serializer for the ManufacturerPartAttachment class."""
@ -179,37 +202,14 @@ class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
model = ManufacturerPartAttachment model = ManufacturerPartAttachment
fields = [ fields = InvenTreeAttachmentSerializer.attachment_fields([
'pk',
'manufacturer_part', 'manufacturer_part',
'attachment', ])
'filename',
'link',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [
'upload_date',
]
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer): class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""Serializer for the ManufacturerPartParameter model.""" """Serializer for the ManufacturerPartParameter model."""
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required"""
man_detail = kwargs.pop('manufacturer_part_detail', False)
super().__init__(*args, **kwargs)
if not man_detail:
self.fields.pop('manufacturer_part_detail')
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -224,66 +224,20 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
'units', 'units',
] ]
class SupplierPartSerializer(InvenTreeModelSerializer):
"""Serializer for SupplierPart object."""
# Annotated field showing total in-stock quantity
in_stock = serializers.FloatField(read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
manufacturer_detail = CompanyBriefSerializer(source='manufacturer_part.manufacturer', many=False, read_only=True)
pretty_name = serializers.CharField(read_only=True)
pack_size = serializers.FloatField(label=_('Pack Quantity'))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required""" """Initialize this serializer with extra detail fields as required"""
man_detail = kwargs.pop('manufacturer_part_detail', False)
# Check if 'available' quantity was supplied
self.has_available_quantity = 'available' in kwargs.get('data', {})
brief = kwargs.pop('brief', False)
detail_default = not brief
part_detail = kwargs.pop('part_detail', detail_default)
supplier_detail = kwargs.pop('supplier_detail', detail_default)
manufacturer_detail = kwargs.pop('manufacturer_detail', detail_default)
prettify = kwargs.pop('pretty', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if part_detail is not True: if not man_detail:
self.fields.pop('part_detail')
if supplier_detail is not True:
self.fields.pop('supplier_detail')
if manufacturer_detail is not True:
self.fields.pop('manufacturer_detail')
self.fields.pop('manufacturer_part_detail') self.fields.pop('manufacturer_part_detail')
if prettify is not True: manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
self.fields.pop('pretty_name')
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
manufacturer = serializers.CharField(read_only=True) class SupplierPartSerializer(InvenTreeModelSerializer):
"""Serializer for SupplierPart object."""
MPN = serializers.CharField(read_only=True)
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True)
# Date fields
updated = serializers.DateTimeField(allow_null=True, read_only=True)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -320,6 +274,63 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'barcode_hash', 'barcode_hash',
] ]
def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required"""
# Check if 'available' quantity was supplied
self.has_available_quantity = 'available' in kwargs.get('data', {})
brief = kwargs.pop('brief', False)
detail_default = not brief
part_detail = kwargs.pop('part_detail', detail_default)
supplier_detail = kwargs.pop('supplier_detail', detail_default)
manufacturer_detail = kwargs.pop('manufacturer_detail', detail_default)
prettify = kwargs.pop('pretty', False)
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
if supplier_detail is not True:
self.fields.pop('supplier_detail')
if manufacturer_detail is not True:
self.fields.pop('manufacturer_detail')
self.fields.pop('manufacturer_part_detail')
if prettify is not True:
self.fields.pop('pretty_name')
# Annotated field showing total in-stock quantity
in_stock = serializers.FloatField(read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
manufacturer_detail = CompanyBriefSerializer(source='manufacturer_part.manufacturer', many=False, read_only=True)
pretty_name = serializers.CharField(read_only=True)
pack_size = serializers.FloatField(label=_('Pack Quantity'))
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
manufacturer = serializers.CharField(read_only=True)
MPN = serializers.CharField(read_only=True)
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True)
# Date fields
updated = serializers.DateTimeField(allow_null=True, read_only=True)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annotate the SupplierPart queryset with extra fields: """Annotate the SupplierPart queryset with extra fields:
@ -375,6 +386,22 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
class SupplierPriceBreakSerializer(InvenTreeModelSerializer): class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
"""Serializer for SupplierPriceBreak object.""" """Serializer for SupplierPriceBreak object."""
class Meta:
"""Metaclass options."""
model = SupplierPriceBreak
fields = [
'pk',
'part',
'part_detail',
'quantity',
'price',
'price_currency',
'supplier',
'supplier_detail',
'updated',
]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra fields as required""" """Initialize this serializer with extra fields as required"""
@ -397,11 +424,7 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
label=_('Price'), label=_('Price'),
) )
price_currency = serializers.ChoiceField( price_currency = InvenTreeCurrencySerializer()
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
)
supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True) supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True)
@ -409,19 +432,3 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
# Detail serializer for SupplierPart # Detail serializer for SupplierPart
part_detail = SupplierPartSerializer(source='part', brief=True, many=False, read_only=True) part_detail = SupplierPartSerializer(source='part', brief=True, many=False, read_only=True)
class Meta:
"""Metaclass options."""
model = SupplierPriceBreak
fields = [
'pk',
'part',
'part_detail',
'quantity',
'price',
'price_currency',
'supplier',
'supplier_detail',
'updated',
]

View File

@ -1,6 +1,7 @@
{% extends "company/company_base.html" %} {% extends "company/company_base.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block sidebar %} {% block sidebar %}
{% include 'company/sidebar.html' %} {% include 'company/sidebar.html' %}
@ -137,6 +138,8 @@
</div> </div>
</div> </div>
{% if company.is_customer %}
{% if roles.sales_order.view %}
<div class='panel panel-hidden' id='panel-sales-orders'> <div class='panel panel-hidden' id='panel-sales-orders'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-wrap'> <div class='d-flex flex-wrap'>
@ -162,7 +165,9 @@
</table> </table>
</div> </div>
</div> </div>
{% endif %}
{% if roles.stock.view %}
<div class='panel panel-hidden' id='panel-assigned-stock'> <div class='panel panel-hidden' id='panel-assigned-stock'>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Assigned Stock" %}</h4> <h4>{% trans "Assigned Stock" %}</h4>
@ -175,9 +180,40 @@
</div> </div>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table> <table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
</div> </div>
</div> </div>
{% endif %}
{% if roles.return_order.view and return_order_enabled %}
<div class='panel panel-hidden' id='panel-return-orders'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Return Orders" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.return_order.add %}
<button class='btn btn-success' type='button' id='new-return-order' title='{% trans "Create new return order" %}'>
<div class='fas fa-plus-circle'></div> {% trans "New Return Order" %}
</button>
{% endif %}
</div>
</div>
</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>
</div>
{% endif %}
{% endif %}
<div class='panel panel-hidden' id='panel-company-notes'> <div class='panel panel-hidden' id='panel-company-notes'>
<div class='panel-heading'> <div class='panel-heading'>
@ -194,11 +230,86 @@
</div> </div>
</div> </div>
<div class='panel panel-hidden' id='panel-company-contacts'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Company Contacts" %}</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-contact' title='{% trans "Add Contact" %}'>
<div class='fas fa-plus-circle'></div> {% trans "Add Contact" %}
</button>
{% endif %}
</div>
</div>
</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-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "attachment_button.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% include "attachment_table.html" %}
</div>
</div>
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
onPanelLoad("attachments", function() {
loadAttachmentTable('{% url "api-company-attachment-list" %}', {
filters: {
company: {{ company.pk }},
},
fields: {
company: {
value: {{ company.pk }},
hidden: true
}
}
});
});
// Callback function when the 'contacts' panel is loaded
onPanelLoad('company-contacts', function() {
loadContactTable('#contacts-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-contact').click(function() {
createContact({
company: {{ company.pk }},
onSuccess: function() {
$('#contacts-table').bootstrapTable('refresh');
}
});
});
});
// Callback function when the 'notes' panel is loaded
onPanelLoad('company-notes', function() { onPanelLoad('company-notes', function() {
setupNotesField( setupNotesField(
@ -207,18 +318,7 @@
{ {
editable: true, editable: true,
} }
) );
});
loadStockTable($("#assigned-stock-table"), {
params: {
customer: {{ company.id }},
part_detail: true,
location_detail: true,
},
url: "{% url 'api-stock-list' %}",
filterKey: "customerstock",
filterTarget: '#filter-list-customerstock',
}); });
onPanelLoad('company-stock', function() { onPanelLoad('company-stock', function() {
@ -239,20 +339,65 @@
}); });
{% if company.is_customer %} {% if company.is_customer %}
{% if return_order_enabled %}
// Callback function when the 'return orders' panel is loaded
onPanelLoad('return-orders', function() {
{% if roles.return_order.view %}
loadReturnOrderTable('#return-order-table', {
params: {
customer: {{ company.pk }},
}
});
{% endif %}
{% if roles.return_order.add %}
$('#new-return-order').click(function() {
createReturnOrder({
customer: {{ company.pk }},
});
});
{% endif %}
});
{% endif %}
// Callback function when the 'assigned stock' panel is loaded
onPanelLoad('assigned-stock', function() {
{% if roles.stock.view %}
loadStockTable($("#assigned-stock-table"), {
params: {
customer: {{ company.id }},
part_detail: true,
location_detail: true,
},
url: "{% url 'api-stock-list' %}",
filterKey: "customerstock",
filterTarget: '#filter-list-customerstock',
});
{% endif %}
});
// Callback function when the 'sales orders' panel is loaded
onPanelLoad('sales-orders', function() { onPanelLoad('sales-orders', function() {
{% if roles.sales_order.view %}
loadSalesOrderTable("#sales-order-table", { loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}", url: "{% url 'api-so-list' %}",
params: { params: {
customer: {{ company.id }}, customer: {{ company.id }},
} }
}); });
{% endif %}
{% if roles.salse_order.add %}
$("#new-sales-order").click(function() { $("#new-sales-order").click(function() {
createSalesOrder({ createSalesOrder({
customer: {{ company.pk }}, customer: {{ company.pk }},
}); });
}); });
{% endif %}
}); });
{% endif %} {% endif %}
@ -291,7 +436,7 @@
createManufacturerPart({ createManufacturerPart({
manufacturer: {{ company.pk }}, manufacturer: {{ company.pk }},
onSuccess: function() { onSuccess: function() {
$("#part-table").bootstrapTable("refresh"); $("#part-table").bootstrapTable('refresh');
} }
}); });
}); });
@ -313,7 +458,7 @@
deleteManufacturerParts(selections, { deleteManufacturerParts(selections, {
success: function() { success: function() {
$("#manufacturer-part-table").bootstrapTable("refresh"); $("#manufacturer-part-table").bootstrapTable('refresh');
} }
}); });
}); });

View File

@ -209,26 +209,8 @@ onPanelLoad("attachments", function() {
} }
} }
}); });
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-manufacturer-part-attachment-list" %}',
{
data: {
manufacturer_part: {{ part.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
}); });
function reloadParameters() {
$("#parameter-table").bootstrapTable("refresh");
}
$('#parameter-create').click(function() { $('#parameter-create').click(function() {
constructForm('{% url "api-manufacturer-part-parameter-list" %}', { constructForm('{% url "api-manufacturer-part-parameter-list" %}', {
@ -243,7 +225,7 @@ $('#parameter-create').click(function() {
} }
}, },
title: '{% trans "Add Parameter" %}', title: '{% trans "Add Parameter" %}',
onSuccess: reloadParameters refreshTable: '#parameter-table',
}); });
}); });

View File

@ -17,10 +17,22 @@
{% include "sidebar_item.html" with label='company-stock' text=text icon="fa-boxes" %} {% include "sidebar_item.html" with label='company-stock' text=text icon="fa-boxes" %}
{% endif %} {% endif %}
{% if company.is_customer %} {% if company.is_customer %}
{% if roles.sales_order.view %}
{% trans "Sales Orders" as text %} {% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %} {% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %}
{% endif %}
{% if roles.stock.view %}
{% trans "Assigned Stock Items" as text %} {% trans "Assigned Stock Items" as text %}
{% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %} {% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %}
{% endif %} {% endif %}
{% if roles.return_order.view and return_order_enabled %}
{% trans "Return Orders" as text %}
{% include "sidebar_item.html" with label='return-orders' text=text icon="fa-undo" %}
{% endif %}
{% endif %}
{% trans "Contacts" as text %}
{% include "sidebar_item.html" with label='company-contacts' text=text icon="fa-users" %}
{% trans "Notes" as text %} {% trans "Notes" as text %}
{% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %} {% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}

View File

@ -116,13 +116,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{% decimal part.available %}<span class='badge bg-dark rounded-pill float-right'>{% render_date part.availability_updated %}</span></td> <td>{% decimal part.available %}<span class='badge bg-dark rounded-pill float-right'>{% render_date part.availability_updated %}</span></td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.barcode_hash %} {% include "barcode_data.html" with instance=part %}
<tr>
<td><span class='fas fa-barcode'></span></td>
<td>{% trans "Barcode Identifier" %}</td>
<td {% if part.barcode_data %}title='{{ part.barcode_data }}'{% endif %}>{{ part.barcode_hash }}</td>
</tr>
{% endif %}
</table> </table>
{% endblock details %} {% endblock details %}
@ -270,10 +264,10 @@ src="{% static 'img/blank_image.png' %}"
{% if barcodes %} {% if barcodes %}
$("#show-qr-code").click(function() { $("#show-qr-code").click(function() {
launchModalForm("{% url 'supplier-part-qr' part.pk %}", showQRDialog(
{ '{% trans "Supplier Part QR Code" %}',
no_post: true, '{"supplierpart": {{ part.pk }}}'
}); );
}); });
$("#barcode-link").click(function() { $("#barcode-link").click(function() {
@ -299,27 +293,12 @@ loadSupplierPriceBreakTable({
}); });
$('#new-price-break').click(function() { $('#new-price-break').click(function() {
createSupplierPartPriceBreak({{ part.pk }}, {
constructForm( onSuccess: function() {
'{% url "api-part-supplier-price-list" %}', $("#price-break-table").bootstrapTable('refresh');
{
method: 'POST',
fields: {
quantity: {},
part: {
value: {{ part.pk }},
hidden: true,
},
price: {},
price_currency: {
},
},
title: '{% trans "Add Price Break" %}',
onSuccess: function() {
$("#price-break-table").bootstrapTable("refresh");
}
} }
); });
}); });
loadPurchaseOrderTable($("#purchase-order-table"), { loadPurchaseOrderTable($("#purchase-order-table"), {

View File

@ -1,33 +0,0 @@
{% load i18n %}
{% load inventree_extras %}
<ul class='list-group'>
<li class='list-group-item'>
<a href='#' id='supplier-part-menu-toggle'>
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
</a>
</li>
<li class='list-group-item' title='{% trans "Supplier Part Stock" %}'>
<a href='#' id='select-stock' class='nav-toggle'>
<span class='fas fa-boxes sidebar-icon'></span>
{% trans "Stock" %}
</a>
</li>
<li class='list-group-item' title='{% trans "Supplier Part Orders" %}'>
<a href='#' id='select-purchase-orders' class='nav-toggle'>
<span class='fas fa-shopping-cart sidebar-icon'></span>
{% trans "Orders" %}
</a>
</li>
<li class='list-group-item' title='{% trans "Supplier Part Pricing" %}'>
<a href='#' id='select-pricing' class='nav-toggle'>
<span class='fas fa-dollar-sign sidebar-icon'></span>
{% trans "Pricing" %}
</a>
</li>
</ul>

View File

@ -6,7 +6,7 @@ from rest_framework import status
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from .models import Company, SupplierPart from .models import Company, Contact, SupplierPart
class CompanyTest(InvenTreeAPITestCase): class CompanyTest(InvenTreeAPITestCase):
@ -17,11 +17,14 @@ class CompanyTest(InvenTreeAPITestCase):
'purchase_order.change', 'purchase_order.change',
] ]
def setUp(self): @classmethod
def setUpTestData(cls):
"""Perform initialization for the unit test class""" """Perform initialization for the unit test class"""
super().setUp()
self.acme = Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True) super().setUpTestData()
# Create some company objects to work with
cls.acme = Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True)
Company.objects.create(name='Drippy Cup Co.', description='Customer', is_customer=True, is_supplier=False) Company.objects.create(name='Drippy Cup Co.', description='Customer', is_customer=True, is_supplier=False)
Company.objects.create(name='Sippy Cup Emporium', description='Another supplier') Company.objects.create(name='Sippy Cup Emporium', description='Another supplier')
@ -137,6 +140,144 @@ class CompanyTest(InvenTreeAPITestCase):
self.assertTrue('currency' in response.data) self.assertTrue('currency' in response.data)
class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models"""
roles = []
@classmethod
def setUpTestData(cls):
"""Perform init for this test class"""
super().setUpTestData()
# Create some companies
companies = [
Company(
name=f"Company {idx}",
description="Some company"
) for idx in range(3)
]
Company.objects.bulk_create(companies)
contacts = []
# Create some contacts
for cmp in Company.objects.all():
contacts += [
Contact(
company=cmp,
name=f"My name {idx}",
) for idx in range(3)
]
Contact.objects.bulk_create(contacts)
cls.url = reverse('api-contact-list')
def test_list(self):
"""Test company list API endpoint"""
# List all results
response = self.get(self.url, {}, expected_code=200)
self.assertEqual(len(response.data), 9)
for result in response.data:
for key in ['name', 'email', 'pk', 'company']:
self.assertIn(key, result)
# Filter by particular company
for cmp in Company.objects.all():
response = self.get(
self.url,
{
'company': cmp.pk,
},
expected_code=200
)
self.assertEqual(len(response.data), 3)
def test_create(self):
"""Test that we can create a new Contact object via the API"""
n = Contact.objects.count()
company = Company.objects.first()
# Without required permissions, creation should fail
self.post(
self.url,
{
'company': company.pk,
'name': 'Joe Bloggs',
},
expected_code=403
)
self.assignRole('return_order.add')
self.post(
self.url,
{
'company': company.pk,
'name': 'Joe Bloggs',
},
expected_code=201
)
self.assertEqual(Contact.objects.count(), n + 1)
def test_edit(self):
"""Test that we can edit a Contact via the API"""
url = reverse('api-contact-detail', kwargs={'pk': 1})
# Retrieve detail view
data = self.get(url, expected_code=200).data
for key in ['pk', 'name', 'role']:
self.assertIn(key, data)
self.patch(
url,
{
'role': 'model',
},
expected_code=403
)
self.assignRole('purchase_order.change')
self.patch(
url,
{
'role': 'x',
},
expected_code=200
)
contact = Contact.objects.get(pk=1)
self.assertEqual(contact.role, 'x')
def test_delete(self):
"""Tests that we can delete a Contact via the API"""
url = reverse('api-contact-detail', kwargs={'pk': 6})
# Delete (without required permissions)
self.delete(url, expected_code=403)
self.assignRole('sales_order.delete')
self.delete(url, expected_code=204)
# Try to access again (gone!)
self.get(url, expected_code=404)
class ManufacturerTest(InvenTreeAPITestCase): class ManufacturerTest(InvenTreeAPITestCase):
"""Series of tests for the Manufacturer DRF API.""" """Series of tests for the Manufacturer DRF API."""

View File

@ -26,8 +26,12 @@ class CompanySimpleTest(TestCase):
'price_breaks', 'price_breaks',
] ]
def setUp(self): @classmethod
def setUpTestData(cls):
"""Perform initialization for the tests in this class""" """Perform initialization for the tests in this class"""
super().setUpTestData()
Company.objects.create(name='ABC Co.', Company.objects.create(name='ABC Co.',
description='Seller of ABC products', description='Seller of ABC products',
website='www.abc-sales.com', website='www.abc-sales.com',
@ -35,10 +39,10 @@ class CompanySimpleTest(TestCase):
is_customer=False, is_customer=False,
is_supplier=True) is_supplier=True)
self.acme0001 = SupplierPart.objects.get(SKU='ACME0001') cls.acme0001 = SupplierPart.objects.get(SKU='ACME0001')
self.acme0002 = SupplierPart.objects.get(SKU='ACME0002') cls.acme0002 = SupplierPart.objects.get(SKU='ACME0002')
self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS') cls.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312') cls.zergm312 = SupplierPart.objects.get(SKU='ZERGM312')
def test_company_model(self): def test_company_model(self):
"""Tests for the company model data""" """Tests for the company model data"""
@ -128,6 +132,23 @@ class CompanySimpleTest(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
company.full_clean() company.full_clean()
def test_metadata(self):
"""Unit tests for the metadata field."""
p = Company.objects.first()
self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)
class ContactSimpleTest(TestCase): class ContactSimpleTest(TestCase):
"""Unit tests for the Contact model""" """Unit tests for the Contact model"""
@ -201,3 +222,21 @@ class ManufacturerPartSimpleTest(TestCase):
Part.objects.get(pk=self.part.id).delete() Part.objects.get(pk=self.part.id).delete()
# Check that ManufacturerPart was deleted # Check that ManufacturerPart was deleted
self.assertEqual(ManufacturerPart.objects.count(), 3) self.assertEqual(ManufacturerPart.objects.count(), 3)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [ManufacturerPart, SupplierPart]:
p = model.objects.first()
self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)

View File

@ -1,13 +1,13 @@
"""URL lookup for Company app.""" """URL lookup for Company app."""
from django.urls import include, re_path from django.urls import include, path, re_path
from . import views from . import views
company_urls = [ company_urls = [
# Detail URLs for a specific Company instance # Detail URLs for a specific Company instance
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'), re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'),
])), ])),
@ -21,12 +21,11 @@ company_urls = [
manufacturer_part_urls = [ manufacturer_part_urls = [
re_path(r'^(?P<pk>\d+)/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'), path(r'<int:pk>/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
] ]
supplier_part_urls = [ supplier_part_urls = [
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path('^qr_code/?', views.SupplierPartQRCode.as_view(), name='supplier-part-qr'),
re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'), re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
])) ]))

View File

@ -4,7 +4,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from InvenTree.views import InvenTreeRoleMixin, QRCodeView from InvenTree.views import InvenTreeRoleMixin
from plugin.views import InvenTreePluginViewMixin from plugin.views import InvenTreePluginViewMixin
from .models import Company, ManufacturerPart, SupplierPart from .models import Company, ManufacturerPart, SupplierPart
@ -112,18 +112,3 @@ class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
context_object_name = 'part' context_object_name = 'part'
queryset = SupplierPart.objects.all() queryset = SupplierPart.objects.all()
permission_required = 'purchase_order.view' permission_required = 'purchase_order.view'
class SupplierPartQRCode(QRCodeView):
"""View for displaying a QR code for a StockItem object."""
ajax_form_title = _("Stock Item QR Code")
role_required = 'stock.view'
def get_qr_data(self):
"""Generate QR code data for the StockItem."""
try:
part = SupplierPart.objects.get(id=self.pk)
return part.format_barcode()
except SupplierPart.DoesNotExist:
return None

View File

@ -119,6 +119,10 @@ plugins_enabled: False
#plugin_file: '/path/to/plugins.txt' #plugin_file: '/path/to/plugins.txt'
#plugin_dir: '/path/to/plugins/' #plugin_dir: '/path/to/plugins/'
# Set this variable to True to enable auto-migrations
# Alternatively, use the environment variable INVENTREE_AUTO_UPDATE
auto_update: False
# Allowed hosts (see ALLOWED_HOSTS in Django settings documentation) # Allowed hosts (see ALLOWED_HOSTS in Django settings documentation)
# A list of strings representing the host/domain names that this Django site can serve. # A list of strings representing the host/domain names that this Django site can serve.
# Default behaviour is to allow all hosts (THIS IS NOT SECURE!) # Default behaviour is to allow all hosts (THIS IS NOT SECURE!)

View File

@ -3,14 +3,17 @@
from django.conf import settings from django.conf import settings
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.urls import include, re_path from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page, never_cache
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
import common.models import common.models
import InvenTree.helpers import InvenTree.helpers
from InvenTree.api import MetadataView
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
from part.models import Part from part.models import Part
@ -45,7 +48,7 @@ class LabelFilterMixin:
ids = [] ids = []
# Construct a list of possible query parameter value options # Construct a list of possible query parameter value options
# e.g. if self.ITEM_KEY = 'part' -> ['part', 'part', 'parts', parts[]'] # e.g. if self.ITEM_KEY = 'part' -> ['part', 'part[]', 'parts', parts[]']
for k in [self.ITEM_KEY + x for x in ['', '[]', 's', 's[]']]: for k in [self.ITEM_KEY + x for x in ['', '[]', 's', 's[]']]:
if ids := self.request.query_params.getlist(k, []): if ids := self.request.query_params.getlist(k, []):
# Return the first list of matches # Return the first list of matches
@ -121,7 +124,7 @@ class LabelListView(LabelFilterMixin, ListAPI):
filter_backends = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
filters.SearchFilter InvenTreeSearchFilter
] ]
filterset_fields = [ filterset_fields = [
@ -134,9 +137,15 @@ class LabelListView(LabelFilterMixin, ListAPI):
] ]
@method_decorator(cache_page(5), name='dispatch')
class LabelPrintMixin(LabelFilterMixin): class LabelPrintMixin(LabelFilterMixin):
"""Mixin for printing labels.""" """Mixin for printing labels."""
@method_decorator(never_cache)
def dispatch(self, *args, **kwargs):
"""Prevent caching when printing report templates"""
return super().dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Perform a GET request against this endpoint to print labels""" """Perform a GET request against this endpoint to print labels"""
return self.print(request, self.get_items()) return self.print(request, self.get_items())
@ -185,7 +194,7 @@ class LabelPrintMixin(LabelFilterMixin):
outputs = [] outputs = []
# In debug mode, generate single HTML output, rather than PDF # In debug mode, generate single HTML output, rather than PDF
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE', cache=False)
label_name = "label.pdf" label_name = "label.pdf"
@ -260,7 +269,7 @@ class LabelPrintMixin(LabelFilterMixin):
pdf = outputs[0].get_document().copy(pages).write_pdf() pdf = outputs[0].get_document().copy(pages).write_pdf()
inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user) inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user, cache=False)
return InvenTree.helpers.DownloadFile( return InvenTree.helpers.DownloadFile(
pdf, pdf,
@ -270,7 +279,17 @@ class LabelPrintMixin(LabelFilterMixin):
) )
class StockItemLabelList(LabelListView): class StockItemLabelMixin:
"""Mixin for StockItemLabel endpoints"""
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
ITEM_MODEL = StockItem
ITEM_KEY = 'item'
class StockItemLabelList(StockItemLabelMixin, LabelListView):
"""API endpoint for viewing list of StockItemLabel objects. """API endpoint for viewing list of StockItemLabel objects.
Filterable by: Filterable by:
@ -279,32 +298,30 @@ class StockItemLabelList(LabelListView):
- item: Filter by single stock item - item: Filter by single stock item
- items: Filter by list of stock items - items: Filter by list of stock items
""" """
pass
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
ITEM_MODEL = StockItem
ITEM_KEY = 'item'
class StockItemLabelDetail(RetrieveUpdateDestroyAPI): class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockItemLabel object.""" """API endpoint for a single StockItemLabel object."""
pass
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
class StockItemLabelPrint(LabelPrintMixin, RetrieveAPI): class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockItemLabel object.""" """API endpoint for printing a StockItemLabel object."""
pass
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
ITEM_MODEL = StockItem
ITEM_KEY = 'item'
class StockLocationLabelList(LabelListView): class StockLocationLabelMixin:
"""Mixin for StockLocationLabel endpoints"""
queryset = StockLocationLabel.objects.all()
serializer_class = StockLocationLabelSerializer
ITEM_MODEL = StockLocation
ITEM_KEY = 'location'
class StockLocationLabelList(StockLocationLabelMixin, LabelListView):
"""API endpoint for viewiing list of StockLocationLabel objects. """API endpoint for viewiing list of StockLocationLabel objects.
Filterable by: Filterable by:
@ -313,56 +330,41 @@ class StockLocationLabelList(LabelListView):
- location: Filter by a single stock location - location: Filter by a single stock location
- locations: Filter by list of stock locations - locations: Filter by list of stock locations
""" """
pass
queryset = StockLocationLabel.objects.all()
serializer_class = StockLocationLabelSerializer
ITEM_MODEL = StockLocation
ITEM_KEY = 'location'
class StockLocationLabelDetail(RetrieveUpdateDestroyAPI): class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockLocationLabel object.""" """API endpoint for a single StockLocationLabel object."""
pass
queryset = StockLocationLabel.objects.all()
serializer_class = StockLocationLabelSerializer
class StockLocationLabelPrint(LabelPrintMixin, RetrieveAPI): class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockLocationLabel object.""" """API endpoint for printing a StockLocationLabel object."""
pass
queryset = StockLocationLabel.objects.all()
seiralizer_class = StockLocationLabelSerializer
ITEM_MODEL = StockLocation
ITEM_KEY = 'location'
class PartLabelList(LabelListView): class PartLabelMixin:
"""Mixin for PartLabel endpoints"""
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
ITEM_MODEL = Part
ITEM_KEY = 'part'
class PartLabelList(PartLabelMixin, LabelListView):
"""API endpoint for viewing list of PartLabel objects.""" """API endpoint for viewing list of PartLabel objects."""
pass
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
ITEM_MODEL = Part
ITEM_KEY = 'part'
class PartLabelDetail(RetrieveUpdateDestroyAPI): class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single PartLabel object.""" """API endpoint for a single PartLabel object."""
pass
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
class PartLabelPrint(LabelPrintMixin, RetrieveAPI): class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a PartLabel object.""" """API endpoint for printing a PartLabel object."""
pass
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
ITEM_MODEL = Part
ITEM_KEY = 'part'
label_api_urls = [ label_api_urls = [
@ -370,8 +372,9 @@ label_api_urls = [
# Stock item labels # Stock item labels
re_path(r'stock/', include([ re_path(r'stock/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'), re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'),
re_path(r'metadata/', MetadataView.as_view(), {'model': StockItemLabel}, name='api-stockitem-label-metadata'),
re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'), re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
])), ])),
@ -382,8 +385,9 @@ label_api_urls = [
# Stock location labels # Stock location labels
re_path(r'location/', include([ re_path(r'location/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'), re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'),
re_path(r'metadata/', MetadataView.as_view(), {'model': StockLocationLabel}, name='api-stocklocation-label-metadata'),
re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'), re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
])), ])),
@ -394,8 +398,9 @@ label_api_urls = [
# Part labels # Part labels
re_path(r'^part/', include([ re_path(r'^part/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'), re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'),
re_path(r'^metadata/', MetadataView.as_view(), {'model': PartLabel}, name='api-part-label-metadata'),
re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'), re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'),
])), ])),

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0008_auto_20210708_2106'),
]
operations = [
migrations.AddField(
model_name='partlabel',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='stockitemlabel',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -17,6 +17,7 @@ import common.models
import part.models import part.models
import stock.models import stock.models
from InvenTree.helpers import normalize, validateFilterString from InvenTree.helpers import normalize, validateFilterString
from plugin.models import MetadataMixin
try: try:
from django_weasyprint import WeasyTemplateResponseMixin from django_weasyprint import WeasyTemplateResponseMixin
@ -70,7 +71,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
self.pdf_filename = kwargs.get('filename', 'label.pdf') self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(models.Model): class LabelTemplate(MetadataMixin, models.Model):
"""Base class for generic, filterable labels.""" """Base class for generic, filterable labels."""
class Meta: class Meta:

View File

@ -9,8 +9,6 @@ from .models import PartLabel, StockItemLabel, StockLocationLabel
class StockItemLabelSerializer(InvenTreeModelSerializer): class StockItemLabelSerializer(InvenTreeModelSerializer):
"""Serializes a StockItemLabel object.""" """Serializes a StockItemLabel object."""
label = InvenTreeAttachmentSerializerField(required=True)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -24,12 +22,12 @@ class StockItemLabelSerializer(InvenTreeModelSerializer):
'enabled', 'enabled',
] ]
label = InvenTreeAttachmentSerializerField(required=True)
class StockLocationLabelSerializer(InvenTreeModelSerializer): class StockLocationLabelSerializer(InvenTreeModelSerializer):
"""Serializes a StockLocationLabel object.""" """Serializes a StockLocationLabel object."""
label = InvenTreeAttachmentSerializerField(required=True)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -43,12 +41,12 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer):
'enabled', 'enabled',
] ]
label = InvenTreeAttachmentSerializerField(required=True)
class PartLabelSerializer(InvenTreeModelSerializer): class PartLabelSerializer(InvenTreeModelSerializer):
"""Serializes a PartLabel object.""" """Serializes a PartLabel object."""
label = InvenTreeAttachmentSerializerField(required=True)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -61,3 +59,5 @@ class PartLabelSerializer(InvenTreeModelSerializer):
'filters', 'filters',
'enabled', 'enabled',
] ]
label = InvenTreeAttachmentSerializerField(required=True)

View File

@ -27,9 +27,10 @@ class LabelTest(InvenTreeAPITestCase):
'stock' 'stock'
] ]
def setUp(self) -> None: @classmethod
def setUpTestData(cls):
"""Ensure that some label instances exist as part of init routine""" """Ensure that some label instances exist as part of init routine"""
super().setUp() super().setUpTestData()
apps.get_app_config('label').create_labels() apps.get_app_config('label').create_labels()
def test_default_labels(self): def test_default_labels(self):
@ -129,3 +130,21 @@ class LabelTest(InvenTreeAPITestCase):
self.assertIn("http://testserver/part/1/", content) self.assertIn("http://testserver/part/1/", content)
self.assertIn("image: /static/img/blank_image.png", content) self.assertIn("image: /static/img/blank_image.png", content)
self.assertIn("logo: /static/img/inventree.png", content) self.assertIn("logo: /static/img/inventree.png", content)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [StockItemLabel, StockLocationLabel, PartLabel]:
p = model.objects.first()
self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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