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
attributes:
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:
- label: "I checked and didn't find a similar issue"
required: true

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@ -19,17 +19,16 @@ repos:
hooks:
- id: flake8
additional_dependencies: [
'flake8-bugbear',
'flake8-docstrings',
'flake8-string-format',
'pep8-naming ',
]
- repo: https://github.com/pycqa/isort
rev: '5.11.4'
rev: '5.12.0'
hooks:
- id: isort
- repo: https://github.com/jazzband/pip-tools
rev: 6.12.1
rev: 6.12.3
hooks:
- id: pip-compile
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
```
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

View File

@ -9,8 +9,7 @@
# - Runs InvenTree web server under django development server
# - Monitors source files for any changes, and live-reloads server
FROM python:3.9-slim as base
FROM python:3.9-slim as inventree_base
# Build arguments for this image
ARG commit_hash=""
@ -19,9 +18,6 @@ ARG commit_tag=""
ENV PYTHONUNBUFFERED 1
# Ref: https://github.com/pyca/cryptography/issues/5776
ENV CRYPTOGRAPHY_DONT_BUILD_RUST 1
ENV INVENTREE_LOG_LEVEL="WARNING"
ENV INVENTREE_DOCKER="true"
@ -60,7 +56,7 @@ RUN apt-get update
# Install required system packages
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
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
# Image format support
@ -76,6 +72,14 @@ RUN apt-get install -y --no-install-recommends \
# Update 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
COPY ./docker/requirements.txt 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
# - Starts a gunicorn webserver
FROM base as production
FROM inventree_base as production
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
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/
# 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.utils.translation import gettext_lazy as _
from django_filters.rest_framework import DjangoFilterBackend
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.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.permissions import RolePermission
from part.templatetags.inventree_extras import plugins_info
from plugin.serializers import MetadataSerializer
from .mixins import RetrieveUpdateAPI
from .status import is_worker_running
from .version import (inventreeApiVersion, inventreeInstanceName,
inventreeVersion)
@ -197,14 +201,174 @@ class AttachmentMixin:
RolePermission,
]
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
filters.SearchFilter,
]
filter_backends = SEARCH_ORDER_FILTER
def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()
attachment.user = self.request.user
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.http.response import StreamingHttpResponse
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from rest_framework.test import APITestCase
from plugin import registry
@ -32,48 +33,69 @@ class UserMixin:
# Set list of roles automatically associated with the user
roles = []
def setUp(self):
"""Setup for all tests."""
super().setUp()
@classmethod
def setUpTestData(cls):
"""Run setup for all tests in a given class"""
super().setUpTestData()
# Create a user to log in with
self.user = get_user_model().objects.create_user(
username=self.username,
password=self.password,
email=self.email
cls.user = get_user_model().objects.create_user(
username=cls.username,
password=cls.password,
email=cls.email
)
# Create a group for the user
self.group = Group.objects.create(name='my_test_group')
self.user.groups.add(self.group)
cls.group = Group.objects.create(name='my_test_group')
cls.user.groups.add(cls.group)
if self.superuser:
self.user.is_superuser = True
if cls.superuser:
cls.user.is_superuser = True
if self.is_staff:
self.user.is_staff = True
if cls.is_staff:
cls.user.is_staff = True
self.user.save()
cls.user.save()
# Assign all roles if set
if self.roles == 'all':
self.assignRole(assign_all=True)
if cls.roles == 'all':
cls.assignRole(group=cls.group, assign_all=True)
# else filter the roles
else:
for role in self.roles:
self.assignRole(role)
for role in cls.roles:
cls.assignRole(role=role, group=cls.group)
def setUp(self):
"""Run setup for individual test methods"""
if self.auto_login:
self.client.login(username=self.username, password=self.password)
def assignRole(self, role=None, assign_all: bool = False):
"""Set the user roles for the registered user."""
# role is of the format 'rule.permission' e.g. 'part.add'
@classmethod
def assignRole(cls, role=None, assign_all: bool = False, group=None):
"""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:
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:
@ -105,9 +127,64 @@ class PluginMixin:
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."""
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):
"""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)
if expected_code is not None:
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)
self.checkResponse(url, 'GET', expected_code, response)
return response
@ -150,17 +221,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
response = self.client.post(url, data=data, format=format)
if expected_code is not None:
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)
self.checkResponse(url, 'POST', expected_code, response)
return response
@ -172,8 +233,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
response = self.client.delete(url, data=data, format=format)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'DELETE', expected_code, response)
return response
@ -181,8 +241,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
"""Issue a PATCH request."""
response = self.client.patch(url, data=data, format=format)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'PATCH', expected_code, response)
return response
@ -190,13 +249,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
"""Issue a PUT request."""
response = self.client.put(url, data=data, format=format)
if expected_code is not None:
if response.status_code != expected_code:
print(f"Unexpected response at '{url}':")
print(response.data)
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'PUT', expected_code, response)
return response
@ -204,8 +257,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
"""Issue an OPTIONS request."""
response = self.client.options(url, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'OPTIONS', expected_code, response)
return response

View File

@ -2,11 +2,55 @@
# 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
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
- 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
import InvenTree.tasks
from InvenTree.config import get_setting
from InvenTree.ready import canAppAccessDatabase, isInTestMode
from .config import get_setting
logger = logging.getLogger("inventree")
@ -24,8 +23,18 @@ class InvenTreeConfig(AppConfig):
name = 'InvenTree'
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:
InvenTree.tasks.check_for_migrations(worker=False)
self.remove_obsolete_tasks()
@ -60,7 +69,10 @@ class InvenTreeConfig(AppConfig):
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__}'
InvenTree.tasks.schedule_task(
ref_name,
@ -75,7 +87,7 @@ class InvenTreeConfig(AppConfig):
force_async=True,
)
logger.info("Started background tasks...")
logger.info(f"Started {len(tasks)} scheduled background tasks...")
def collect_tasks(self):
"""Collect all background tasks."""

View File

@ -1,6 +1,7 @@
"""Helper functions for loading InvenTree configuration options."""
import datetime
import json
import logging
import os
import random
@ -13,6 +14,47 @@ CONFIG_DATA = None
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):
"""Shortcut function to determine if a value "looks" like a boolean"""
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):
"""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:
val = typecast(value)
@ -109,6 +160,7 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
return val
except Exception as error:
logger.error(f"Failed to typecast '{env_var}' with value '{value}' to type '{typecast}' with error {error}")
set_metadata(source)
return value

View File

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

View File

@ -55,10 +55,25 @@ def exception_handler(exc, context):
"""Custom exception handler for DRF framework.
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
if settings.SENTRY_ENABLED and settings.SENTRY_DSN and not settings.DEBUG:
# Report this exception to sentry.io
from sentry_sdk import capture_exception
# The following types of errors are ignored, they are "expected"
do_not_report = [
DjangoValidationError,
DRFValidationError,
]
if not any([isinstance(exc, err) for err in do_not_report]):
capture_exception(exc)
# Catch any django validation error, and re-throw a DRF validation error
if isinstance(exc, DjangoValidationError):
exc = DRFValidationError(detail=serializers.as_serializer_error(exc))

View File

@ -1,9 +1,66 @@
"""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.
To use, simply specify this filter in the "filter_backends" section.
@ -74,3 +131,21 @@ class InvenTreeOrderingFilter(OrderingFilter):
ordering.append(a)
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):
"""Form for setting user password."""
class Meta:
"""Metaclass options."""
model = User
fields = [
'enter_password',
'confirm_password',
'old_password',
]
enter_password = forms.CharField(
max_length=100,
min_length=8,
@ -152,16 +162,6 @@ class SetPasswordForm(HelperForm):
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}),
)
class Meta:
"""Metaclass options."""
model = User
fields = [
'enter_password',
'confirm_password',
'old_password',
]
# override allauth
class CustomSignupForm(SignupForm):

View File

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

View File

@ -12,8 +12,15 @@ from django.utils.translation import override as lang_over
def render_file(file_name, source, target, locales, ctx):
"""Renders a file into all provided 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)
with open(target_file, 'w') as localised_file:
with lang_over(locale):
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.utils import model_meta
import InvenTree.permissions
import users.models
from InvenTree.helpers import str2bool
@ -58,7 +59,7 @@ class InvenTreeMetadata(SimpleMetadata):
try:
# 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
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
REFERENCE_PATTERN_SETTING = None
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
@classmethod
def get_reference_pattern(cls):
"""Returns the reference pattern associated with this model.
@ -272,11 +277,6 @@ class ReferenceIndexingMixin(models.Model):
# Check that the reference field can be rebuild
cls.rebuild_reference_field(value, validate=True)
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
@classmethod
def rebuild_reference_field(cls, reference, validate=False):
"""Extract integer out of reference for sorting.
@ -369,6 +369,10 @@ class InvenTreeAttachment(models.Model):
upload_date: Date the file was uploaded
"""
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
def getSubdir(self):
"""Return the subdirectory under which attachments should be stored.
@ -483,11 +487,6 @@ class InvenTreeAttachment(models.Model):
except Exception:
raise ValidationError(_("Error renaming file"))
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
class InvenTreeTree(MPTTModel):
"""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
"""
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):
"""Instance filters for InvenTreeTree models."""
return {
@ -539,18 +565,6 @@ class InvenTreeTree(MPTTModel):
for child in self.get_children():
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(
blank=False,
max_length=100,
@ -717,7 +731,7 @@ class InvenTreeBarcodeMixin(models.Model):
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."""
# Must provide either barcode_hash or barcode_data
@ -740,6 +754,7 @@ class InvenTreeBarcodeMixin(models.Model):
self.barcode_hash = barcode_hash
if save:
self.save()
return True

View File

@ -7,6 +7,21 @@ from rest_framework import permissions
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):
"""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]
# 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:
# 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
model_name = model._meta.model_name
@ -62,9 +81,7 @@ class RolePermission(permissions.BasePermission):
# then we don't need a permission
return True
result = users.models.RuleSet.check_table_permission(user, table, permission)
return result
return users.models.RuleSet.check_table_permission(user, table, permission)
class IsSuperuser(permissions.IsAdminUser):

View File

@ -13,7 +13,7 @@ def isImportingData():
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.
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',
'dumpdata',
'check',
'shell',
'createsuperuser',
'wait_for_db',
'prerender',
@ -42,6 +41,9 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False):
'mediarestore',
]
if not allow_shell:
excluded_commands.append('shell')
if not allow_test:
# Override for testing mode?
excluded_commands.append('test')

View File

@ -21,6 +21,7 @@ from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta
from common.models import InvenTreeSetting
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers import download_image_from_url
@ -66,6 +67,26 @@ class InvenTreeMoneySerializer(MoneyField):
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):
"""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.
"""
@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)
attachment = InvenTreeAttachmentSerializerField(
@ -297,6 +337,8 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
allow_blank=False,
)
upload_date = serializers.DateField(read_only=True)
class InvenTreeImageSerializerField(serializers.ImageField):
"""Custom image serializer.

View File

@ -26,15 +26,25 @@ from sentry_sdk.integrations.django import DjangoIntegration
from . import config
from .config import get_boolean_setting, get_custom_file, get_setting
from .version import inventreeApiVersion
INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
# Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv
if TESTING:
# Use a weaker password hasher for testing (improves testing speed)
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher',]
# 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 TESTING and os.getenv('INVENTREE_DOCKER'):
if os.getenv('INVENTREE_DOCKER'):
# Ensure that sys.path includes global python libs
site_packages = '/usr/local/lib/python3.9/site-packages'
@ -102,8 +112,10 @@ MEDIA_ROOT = config.get_media_dir()
# List of allowed hosts (default = allow all)
ALLOWED_HOSTS = get_setting(
"INVENTREE_ALLOWED_HOSTS",
config_key='allowed_hosts',
default_value=['*']
default_value=['*'],
typecast=list,
)
# Cross Origin Resource Sharing (CORS) options
@ -113,13 +125,16 @@ CORS_URLS_REGEX = r'^/api/.*$'
# Extract CORS options from configuration file
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(
"INVENTREE_CORS_ORIGIN_ALLOW_ALL",
config_key='cors.allow_all',
default_value=False,
)
CORS_ORIGIN_WHITELIST = get_setting(
"INVENTREE_CORS_ORIGIN_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
@ -219,6 +234,7 @@ INSTALLED_APPS = [
'django_otp.plugins.otp_static', # Backup codes
'allauth_2fa', # MFA flow for allauth
'drf_spectacular', # API documentation
'django_ical', # For exporting calendars
]
@ -342,7 +358,7 @@ REST_FRAMEWORK = {
'rest_framework.permissions.DjangoModelPermissions',
'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_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
@ -353,6 +369,15 @@ if DEBUG:
# Enable browsable API if in DEBUG mode
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'
"""
@ -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))
if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover
logger.info("Running with sentry.io integration enabled")
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[DjangoIntegration(), ],
@ -713,7 +741,7 @@ LANGUAGES = [
('th', _('Thai')),
('tr', _('Turkish')),
('vi', _('Vietnamese')),
('zh-cn', _('Chinese')),
('zh-hans', _('Chinese')),
]
# 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
# Currencies available for use
CURRENCIES = get_setting('INVENTREE_CURRENCIES', 'currencies', [
'AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD',
])
CURRENCIES = get_setting(
'INVENTREE_CURRENCIES', 'currencies',
['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD'],
typecast=list,
)
# Maximum number of decimal places for currency rendering
CURRENCY_DECIMAL_PLACES = 6
@ -746,7 +776,7 @@ CURRENCY_DECIMAL_PLACES = 6
# Check that each provided currency is supported
for currency in CURRENCIES:
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)
# Custom currency exchange backend
@ -795,15 +825,12 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
SITE_ID = 1
# 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:
INSTALLED_APPS.append(app) # pragma: no cover
SOCIALACCOUNT_PROVIDERS = get_setting('INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None)
if SOCIALACCOUNT_PROVIDERS is None:
SOCIALACCOUNT_PROVIDERS = {}
SOCIALACCOUNT_PROVIDERS = get_setting('INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None, typecast=dict)
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')
CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {})
if DEBUG:
logger.info("InvenTree running with DEBUG enabled")

View File

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

View File

@ -155,15 +155,14 @@ function inventreeDocReady() {
}
function isFileTransfer(transfer) {
/* Determine if a transfer (e.g. drag-and-drop) is a file transfer
/*
* Determine if a transfer (e.g. drag-and-drop) is a file transfer
*/
function isFileTransfer(transfer) {
return transfer.files.length > 0;
}
function enableDragAndDrop(element, url, options) {
/* Enable drag-and-drop file uploading for a given element.
Params:
@ -176,9 +175,17 @@ function enableDragAndDrop(element, url, options) {
error - Callback function in case of error
method - HTTP method
*/
function enableDragAndDrop(elementId, url, options={}) {
var data = options.data || {};
let element = $(elementId);
if (!element.exists()) {
console.error(`enableDragAndDrop called with invalid target: '${elementId}'`);
return;
}
$(element).on('drop', function(event) {
var transfer = event.originalEvent.dataTransfer;
@ -200,6 +207,11 @@ function enableDragAndDrop(element, url, options) {
formData,
{
success: function(data, status, xhr) {
// Reload a table
if (options.refreshTable) {
reloadBootstrapTable(options.refreshTable);
}
if (options.success) {
options.success(data, status, xhr);
}

File diff suppressed because one or more lines are too long

View File

@ -61,16 +61,10 @@ def is_email_configured():
if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST is not configured")
if not settings.EMAIL_HOST_USER:
configured = False
# Display warning unless in test mode
if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST_USER 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")

View File

@ -31,23 +31,7 @@ class StatusCode:
@classmethod
def list(cls):
"""Return the StatusCode options as a list of mapped key / value items."""
codes = []
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
return list(cls.dict().values())
@classmethod
def text(cls, key):
@ -69,6 +53,62 @@ class StatusCode:
"""All status code labels."""
return cls.options.values()
@classmethod
def names(cls):
"""Return a map of all 'names' of status codes in this class
Will return a dict object, with the attribute name indexed to the integer value.
e.g.
{
'PENDING': 10,
'IN_PROGRESS': 20,
}
"""
keys = cls.keys()
status_names = {}
for d in dir(cls):
if d.startswith('_'):
continue
if d != d.upper():
continue
value = getattr(cls, d, None)
if value is None:
continue
if callable(value):
continue
if type(value) != int:
continue
if value not in keys:
continue
status_names[d] = value
return status_names
@classmethod
def dict(cls):
"""Return a dict representation containing all required information"""
values = {}
for name, value, in cls.names().items():
entry = {
'key': value,
'name': name,
'label': cls.label(value),
}
if hasattr(cls, 'colors'):
if color := cls.colors.get(value, None):
entry['color'] = color
values[name] = entry
return values
@classmethod
def label(cls, value):
"""Return the status code label associated with the provided value."""
@ -131,6 +171,7 @@ class SalesOrderStatus(StatusCode):
"""Defines a set of status codes for a SalesOrder."""
PENDING = 10 # Order is pending
IN_PROGRESS = 15 # Order has been issued, and is in progress
SHIPPED = 20 # Order has been shipped to customer
CANCELLED = 40 # Order has been cancelled
LOST = 50 # Order was lost
@ -138,6 +179,7 @@ class SalesOrderStatus(StatusCode):
options = {
PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
SHIPPED: _("Shipped"),
CANCELLED: _("Cancelled"),
LOST: _("Lost"),
@ -146,6 +188,7 @@ class SalesOrderStatus(StatusCode):
colors = {
PENDING: 'secondary',
IN_PROGRESS: 'primary',
SHIPPED: 'success',
CANCELLED: 'danger',
LOST: 'warning',
@ -155,6 +198,7 @@ class SalesOrderStatus(StatusCode):
# Open orders
OPEN = [
PENDING,
IN_PROGRESS,
]
# Completed orders
@ -247,10 +291,14 @@ class StockHistoryCode(StatusCode):
BUILD_CONSUMED = 57
# Sales order codes
SHIPPED_AGAINST_SALES_ORDER = 60
# Purchase order codes
RECEIVED_AGAINST_PURCHASE_ORDER = 70
# Return order codes
RETURNED_AGAINST_RETURN_ORDER = 80
# Customer actions
SENT_TO_CUSTOMER = 100
RETURNED_FROM_CUSTOMER = 105
@ -289,8 +337,11 @@ class StockHistoryCode(StatusCode):
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
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,
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.exceptions import AppRegistryNotReady
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
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")
@ -67,6 +74,93 @@ def raise_warning(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):
"""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")
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 = {}
# If running within github actions, use authentication token
@ -350,7 +452,10 @@ def check_for_updates():
if 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:
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
common.models.InvenTreeSetting.set_setting(
'INVENTREE_LATEST_VERSION',
'_INVENTREE_LATEST_VERSION',
tag,
None
)
# Record that this task was successful
record_task_success('check_for_updates')
@scheduled_task(ScheduledTask.DAILY)
def update_exchange_rates():
@ -428,68 +536,26 @@ def update_exchange_rates():
@scheduled_task(ScheduledTask.DAILY)
def run_backup():
"""Run the backup command."""
from common.models import InvenTreeSetting
if not InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE', False, cache=False):
# Backups are not enabled - exit early
return
interval = int(InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False))
# Check if should run this task *today*
if not check_daily_holdoff('run_backup', interval):
return
logger.info("Performing automated database backup task")
# Sleep a random number of seconds to prevent worker conflict
time.sleep(random.randint(1, 5))
# 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
if last_success:
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("mediabackup", noinput=True, clean=True, compress=True, interactive=False)
# Record the timestamp of most recent backup success
InvenTreeSetting.set_setting('INVENTREE_BACKUP_SUCCESS', datetime.now().isoformat(), None)
# Record that this task was successful
record_task_success('run_backup')
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,
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.helpers import InvenTreeTestCase
from users.models import RuleSet
from users.models import RuleSet, update_group_roles
class HTMLAPITests(InvenTreeTestCase):
@ -126,6 +126,10 @@ class APITests(InvenTreeAPITestCase):
"""
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')
# 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))
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."""
import os
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.utils import timezone
@ -108,12 +112,41 @@ class InvenTreeTaskTests(TestCase):
def test_task_check_for_updates(self):
"""Test the task check_for_updates."""
# 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
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates)
# Check that setting is not empty
response = InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION')
response = InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION')
self.assertNotEqual(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 os
import time
from datetime import datetime, timedelta
from decimal import Decimal
from unittest import mock
@ -29,20 +30,12 @@ from stock.models import StockItem, StockLocation
from . import config, helpers, ready, status, version
from .tasks import offload_task
from .validators import validate_overage, validate_part_name
from .validators import validate_overage
class ValidatorTest(TestCase):
"""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):
"""Test overage validator."""
validate_overage("100%")
@ -398,10 +391,10 @@ class TestMPTT(TestCase):
'location',
]
def setUp(self):
@classmethod
def setUpTestData(cls):
"""Setup for all tests."""
super().setUp()
super().setUpTestData()
StockLocation.objects.rebuild()
def test_self_as_parent(self):
@ -830,6 +823,17 @@ class TestSettings(helpers.InvenTreeTestCase):
with self.in_env_context({TEST_ENV_NAME: '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):
"""Unit tests for instance name."""
@ -903,6 +907,58 @@ class TestOffloadTask(helpers.InvenTreeTestCase):
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):
"""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.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.urls import build_urls
@ -30,7 +30,7 @@ from stock.api import stock_api_urls
from stock.urls import stock_urls
from users.api import user_urls
from .api import InfoView, NotFoundView
from .api import APISearchView, InfoView, NotFoundView
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
CustomEmailView, CustomLoginView,
CustomPasswordResetFromKeyView,
@ -43,6 +43,10 @@ admin.site.site_header = "InvenTree Admin"
apipatterns = [
# Global search
path('search/', APISearchView.as_view(), name='api-search'),
re_path(r'^settings/', include(settings_api_urls)),
re_path(r'^part/', include(part_api_urls)),
re_path(r'^bom/', include(bom_api_urls)),
@ -58,9 +62,12 @@ apipatterns = [
# Plugin endpoints
path('', include(plugin_api_urls)),
# Webhook enpoint
# Common endpoints enpoint
path('', include(common_api_urls)),
# OpenAPI Schema
re_path('schema/', SpectacularAPIView.as_view(custom_settings={'SCHEMA_PATH_PREFIX': '/api/'}), name='schema'),
# InvenTree information endpoint
path('', InfoView.as_view(), name='api-inventree-info'),
@ -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'^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'^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'^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'^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'^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'),
@ -128,7 +139,7 @@ backendpatterns = [
re_path(r'^auth/?', auth_request),
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 = [

View File

@ -11,8 +11,6 @@ from django.utils.translation import gettext_lazy as _
from jinja2 import Template
from moneyed import CURRENCIES
import common.models
def validate_currency_code(code):
"""Check that a given code is a valid currency code."""
@ -47,50 +45,6 @@ class AllowedURLValidator(validators.URLValidator):
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):
"""Validate the 'reference' field of a PurchaseOrder."""

View File

@ -9,20 +9,23 @@ import subprocess
import django
import common.models
from InvenTree.api_version import INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = "0.11.0 dev"
INVENTREE_SW_VERSION = "0.12.0 dev"
def inventreeInstanceName():
"""Returns the InstanceName settings for the current database."""
import common.models
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
def inventreeInstanceTitle():
"""Returns the InstanceTitle for the current database."""
import common.models
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
else:
@ -64,9 +67,10 @@ def inventreeDocsVersion():
def isInvenTreeUpToDate():
"""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!
if not latest:

View File

@ -320,44 +320,6 @@ class AjaxView(AjaxMixin, View):
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):
"""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.
# 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')
@ -39,17 +50,6 @@ class BuildResource(InvenTreeResource):
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 for managing the Build model via the admin interface"""

View File

@ -1,30 +1,39 @@
"""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.contrib.auth.models import User
from rest_framework import filters
from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView, StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import build.admin
import build.serializers
from build.models import Build, BuildItem, BuildOrderAttachment
import part.models
from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
class BuildFilter(rest_filters.FilterSet):
"""Custom filterset for BuildList API endpoint."""
class Meta:
"""Metaclass options"""
model = Build
fields = [
'parent',
'sales_order',
'part',
]
status = rest_filters.NumberFilter(label='Status')
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):
"""Filter the queryset to either include or exclude orders which are active."""
if str2bool(value):
queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
return queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
else:
queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
return queryset
return queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
def filter_overdue(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are overdue."""
if str2bool(value):
queryset = queryset.filter(Build.OVERDUE_FILTER)
return queryset.filter(Build.OVERDUE_FILTER)
else:
queryset = queryset.exclude(Build.OVERDUE_FILTER)
return queryset
return queryset.exclude(Build.OVERDUE_FILTER)
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)
if value:
queryset = queryset.filter(responsible__in=owners)
return queryset.filter(responsible__in=owners)
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
reference = rest_filters.CharFilter(
@ -84,11 +99,7 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
serializer_class = build.serializers.BuildSerializer
filterset_class = BuildFilter
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
InvenTreeOrderingFilter,
]
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = [
'reference',
@ -157,18 +168,6 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
except (ValueError, Build.DoesNotExist):
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
ancestor = params.get('ancestor', None)
@ -185,12 +184,6 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
except (ValueError, Build.DoesNotExist):
pass
# Filter by associated part?
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(part=part)
# Filter by 'date range'
min_date = params.get('min_date', None)
max_date = params.get('max_date', None)
@ -359,6 +352,34 @@ class BuildItemDetail(RetrieveUpdateDestroyAPI):
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):
"""API endpoint for accessing a list of BuildItem objects.
@ -367,6 +388,7 @@ class BuildItemList(ListCreateAPI):
"""
serializer_class = build.serializers.BuildItemSerializer
filterset_class = BuildItemFilter
def get_serializer(self, *args, **kwargs):
"""Returns a BuildItemSerializer instance based on the request."""
@ -404,24 +426,6 @@ class BuildItemList(ListCreateAPI):
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
output = params.get('output', None)
@ -438,13 +442,6 @@ class BuildItemList(ListCreateAPI):
DjangoFilterBackend,
]
filterset_fields = [
'build',
'stock_item',
'bom_item',
'install_into',
]
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
@ -472,18 +469,21 @@ build_api_urls = [
# Attachments
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'),
])),
# Build Items
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'),
])),
# 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'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
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'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
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'),
])),
# Build order status code information
re_path(r'status/', StatusView.as_view(), {StatusView.MODEL_REF: BuildStatus}, name='api-build-status-codes'),
# 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.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
@ -33,14 +33,16 @@ import InvenTree.ready
import InvenTree.tasks
from plugin.events import trigger_event
from plugin.models import MetadataMixin
import common.notifications
from part import models as PartModels
from stock import models as StockModels
from users import models as UserModels
import part.models
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.
Attributes:
@ -64,6 +66,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
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())
# Global setting for specifying reference pattern
@ -106,11 +113,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
'parent': _('Invalid choice for parent build'),
})
class Meta:
"""Metaclass options for the BuildOrder model"""
verbose_name = _("Build Order")
verbose_name_plural = _("Build Orders")
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'.
@ -162,9 +164,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
title = models.CharField(
verbose_name=_('Description'),
blank=False,
blank=True,
max_length=100,
help_text=_('Brief description of the build')
help_text=_('Brief description of the build (optional)')
)
parent = TreeForeignKey(
@ -278,7 +280,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
)
responsible = models.ForeignKey(
UserModels.Owner,
users.models.Owner,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Responsible'),
@ -394,9 +396,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
if in_stock is not None:
if in_stock:
outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER)
outputs = outputs.filter(stock.models.StockItem.IN_STOCK_FILTER)
else:
outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER)
outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER)
# Filter by 'complete' status
complete = kwargs.get('complete', None)
@ -658,7 +660,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
else:
serial = None
output = StockModels.StockItem.objects.create(
output = stock.models.StockItem.objects.create(
quantity=1,
location=location,
part=self.part,
@ -676,11 +678,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
parts = bom_item.get_valid_parts_for_allocation()
items = StockModels.StockItem.objects.filter(
items = stock.models.StockItem.objects.filter(
part__in=parts,
serial=str(serial),
quantity=1,
).filter(StockModels.StockItem.IN_STOCK_FILTER)
).filter(stock.models.StockItem.IN_STOCK_FILTER)
"""
Test if there is a matching serial number!
@ -700,7 +702,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
else:
"""Create a single build output of the given quantity."""
StockModels.StockItem.objects.create(
stock.models.StockItem.objects.create(
quantity=quantity,
location=location,
part=self.part,
@ -876,7 +878,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
)
# 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
available_stock = available_stock.filter(
@ -1140,7 +1142,7 @@ class BuildOrderAttachment(InvenTreeAttachment):
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.
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)
"""
@staticmethod
def get_api_url():
"""Return the API URL used to access this model"""
return reverse('api-build-item-list')
class Meta:
"""Serializer metaclass"""
unique_together = [
('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):
"""Custom save method for the BuildItem model"""
self.clean()
@ -1219,7 +1221,7 @@ class BuildItem(models.Model):
'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
"""
@ -1258,8 +1260,8 @@ class BuildItem(models.Model):
for idx, ancestor in enumerate(ancestors):
try:
bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
except PartModels.BomItem.DoesNotExist:
bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
except part.models.BomItem.DoesNotExist:
continue
# A matching BOM item has been found!
@ -1349,7 +1351,7 @@ class BuildItem(models.Model):
# Internal model which links part <-> sub_part
# We need to track this separately, to allow for "variant' stock
bom_item = models.ForeignKey(
PartModels.BomItem,
part.models.BomItem,
on_delete=models.CASCADE,
related_name='allocate_build_items',
blank=True, null=True,

View File

@ -30,7 +30,49 @@ from .models import Build, BuildItem, BuildOrderAttachment
class BuildSerializer(InvenTreeModelSerializer):
"""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)
status_text = serializers.CharField(source='get_status_display', 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)
barcode_hash = serializers.CharField(read_only=True)
@staticmethod
def annotate_queryset(queryset):
"""Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
@ -83,46 +127,6 @@ class BuildSerializer(InvenTreeModelSerializer):
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):
"""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"!
"""
class Meta:
"""Serializer metaclass"""
fields = [
'output',
]
output = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
@ -170,12 +180,6 @@ class BuildOutputSerializer(serializers.Serializer):
return output
class Meta:
"""Serializer metaclass"""
fields = [
'output',
]
class BuildOutputCreateSerializer(serializers.Serializer):
"""Serializer for creating a new BuildOutput against a BuildOrder.
@ -633,6 +637,15 @@ class BuildUnallocationSerializer(serializers.Serializer):
class BuildAllocationItemSerializer(serializers.Serializer):
"""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(
queryset=BomItem.objects.all(),
many=False,
@ -693,15 +706,6 @@ class BuildAllocationItemSerializer(serializers.Serializer):
label=_('Build Output'),
)
class Meta:
"""Serializer metaclass"""
fields = [
'bom_item',
'stock_item',
'quantity',
'output',
]
def validate(self, data):
"""Perform data validation for this item"""
super().validate(data)
@ -751,14 +755,14 @@ class BuildAllocationItemSerializer(serializers.Serializer):
class BuildAllocationSerializer(serializers.Serializer):
"""DRF serializer for allocation stock items against a build order."""
items = BuildAllocationItemSerializer(many=True)
class Meta:
"""Serializer metaclass"""
fields = [
'items',
]
items = BuildAllocationItemSerializer(many=True)
def validate(self, data):
"""Validation."""
data = super().validate(data)
@ -870,6 +874,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
class BuildItemSerializer(InvenTreeModelSerializer):
"""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)
part = serializers.IntegerField(source='stock_item.part.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:
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):
"""Serializer for a BuildAttachment."""
@ -929,18 +933,6 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer metaclass"""
model = BuildOrderAttachment
fields = [
'pk',
fields = InvenTreeAttachmentSerializer.attachment_fields([
'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 %}
{% include "admin_button.html" with url=url %}
{% 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 -->
{% if report_enabled %}
<div class='btn-group'>
@ -90,6 +108,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{% trans "Build Description" %}</td>
<td>{{ build.title }}</td>
</tr>
{% include "barcode_data.html" with instance=build %}
</table>
<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>
</div>
{% 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 %}
<div class='alert alert-block alert-info'>
{% object_link 'build-detail' build.parent.id build.parent as link %}
@ -162,7 +174,12 @@ src="{% static 'img/blank_image.png' %}"
</tr>
{% endif %}
<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>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td>
</tr>
@ -247,7 +264,11 @@ src="{% static 'img/blank_image.png' %}"
{% if report_enabled %}
$('#print-build-report').click(function() {
printBuildReports([{{ build.pk }}]);
printReports({
items: [{{ build.pk }}],
key: 'build',
url: '{% url "api-build-report-list" %}',
});
});
{% 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 %}

View File

@ -67,7 +67,7 @@
<td>{% trans "Completed" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td>
</tr>
{% if build.active and build.has_untracked_bom_items %}
{% if build.active and has_untracked_bom_items %}
<tr>
<td><span class='fas fa-list'></span></td>
<td>{% trans "Allocated Parts" %}</td>
@ -179,7 +179,7 @@
<h4>{% trans "Allocate Stock to Build" %}</h4>
{% include "spacer.html" %}
<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" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
</button>
@ -199,7 +199,7 @@
</div>
</div>
<div class='panel-content'>
{% if build.has_untracked_bom_items %}
{% if has_untracked_bom_items %}
{% if build.active %}
{% if build.are_untracked_parts_allocated %}
<div class='alert alert-block alert-success'>
@ -268,24 +268,6 @@
{% endif %}
</ul>
</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' %}
</div>
{% endif %}
@ -372,20 +354,6 @@ onPanelLoad('children', 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" %}', {
filters: {
build: {{ build.pk }},
@ -414,10 +382,6 @@ onPanelLoad('notes', function() {
);
});
function reloadTable() {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
onPanelLoad('outputs', function() {
{% if build.active %}
@ -436,7 +400,7 @@ onPanelLoad('outputs', function() {
{% endif %}
});
{% if build.active and build.has_untracked_bom_items %}
{% if build.active and has_untracked_bom_items %}
function loadUntrackedStockTable() {

View File

@ -26,20 +26,6 @@
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<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" %}
</div>
</div>
@ -62,17 +48,4 @@ loadBuildTable($("#build-table"), {
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 %}

View File

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

View File

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

View File

@ -1,13 +1,13 @@
"""URL lookup for Build app."""
from django.urls import include, re_path
from django.urls import include, path, re_path
from . import views
build_urls = [
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
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()
ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False)
ctx['BuildStatus'] = BuildStatus
ctx['sub_build_count'] = build.sub_build_count()
part = build.part
bom_items = build.bom_items
ctx['part'] = part
ctx['bom_items'] = bom_items
ctx['has_tracked_bom_items'] = build.has_tracked_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.views.decorators.csrf import csrf_exempt
from django_filters.rest_framework import DjangoFilterBackend
from django_q.tasks import async_task
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.permissions import IsAdminUser
from rest_framework.response import Response
@ -20,6 +19,7 @@ import common.models
import common.serializers
from InvenTree.api import BulkDeleteMixin
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
from InvenTree.helpers import inheritors
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI)
@ -167,11 +167,7 @@ class SettingsList(ListAPI):
This is inheritted by all list views for settings.
"""
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = [
'pk',
@ -187,7 +183,7 @@ class SettingsList(ListAPI):
class GlobalSettingsList(SettingsList):
"""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
@ -216,7 +212,7 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
"""
lookup_field = 'key'
queryset = common.models.InvenTreeSetting.objects.all()
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
serializer_class = common.serializers.GlobalSettingsSerializer
def get_object(self):
@ -226,7 +222,10 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
if key not in common.models.InvenTreeSetting.SETTINGS.keys():
raise NotFound()
return common.models.InvenTreeSetting.get_setting_object(key)
return common.models.InvenTreeSetting.get_setting_object(
key,
cache=False, create=True
)
permission_classes = [
permissions.IsAuthenticated,
@ -284,7 +283,11 @@ class UserSettingsDetail(RetrieveUpdateAPI):
if key not in common.models.InvenTreeUserSetting.SETTINGS.keys():
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 = [
UserSettingsPermissions,
@ -332,11 +335,7 @@ class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
permission_classes = [permissions.IsAuthenticated, ]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = [
'category',
@ -401,10 +400,7 @@ class NewsFeedMixin:
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
"""List view for all news items."""
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
filter_backends = ORDER_FILTER
ordering_fields = [
'published',
@ -457,7 +453,7 @@ settings_api_urls = [
# Notification settings
re_path(r'^notification/', include([
# 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
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
@ -486,7 +482,7 @@ common_api_urls = [
# Notifications
re_path(r'^notifications/', include([
# 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'),
])),
# Read all
@ -498,7 +494,7 @@ common_api_urls = [
# News
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'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),

View File

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

View File

@ -46,6 +46,7 @@ import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
import order.validators
from plugin import registry
logger = logging.getLogger('inventree')
@ -179,6 +180,10 @@ class BaseInvenTreeSetting(models.Model):
"""
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
if user is not None:
results = results.filter(user=user)
@ -383,10 +388,10 @@ class BaseInvenTreeSetting(models.Model):
if not 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
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
if create:
@ -852,6 +857,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
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):
"""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': {
'name': _('Automatic Backup'),
'description': _('Enable automatic backup of database and media files'),
@ -981,20 +1003,21 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'INVENTREE_BACKUP_DAYS': {
'name': _('Days Between Backup'),
'name': _('Auto Backup Interval'),
'description': _('Specify number of days between automated backup events'),
'validator': [
int,
MinValueValidator(1),
],
'default': 1,
'units': _('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'),
'default': 30,
'units': 'days',
'units': _('days'),
'validator': [
int,
MinValueValidator(7),
@ -1002,10 +1025,10 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'INVENTREE_DELETE_ERRORS_DAYS': {
'name': _('Delete Error Logs'),
'name': _('Error Log Deletion Interval'),
'description': _('Error logs will be deleted after specified number of days'),
'default': 30,
'units': 'days',
'units': _('days'),
'validator': [
int,
MinValueValidator(7)
@ -1013,10 +1036,10 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'INVENTREE_DELETE_NOTIFICATIONS_DAYS': {
'name': _('Delete Notifications'),
'name': _('Notification Deletion Interval'),
'description': _('User notifications will be deleted after specified number of days'),
'default': 30,
'units': 'days',
'units': _('days'),
'validator': [
int,
MinValueValidator(7),
@ -1048,6 +1071,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
'PART_ENABLE_REVISION': {
'name': _('Part Revisions'),
'description': _('Enable revision field for Part'),
'validator': bool,
'default': True,
},
'PART_IPN_REGEX': {
'name': _('IPN Regex'),
'description': _('Regular expression pattern for matching Part IPN')
@ -1186,9 +1216,20 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'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': {
'name': _('Pricing Decimal Places'),
'description': _('Number of decimal places to display when rendering pricing data'),
'name': _('Maximum Pricing Decimal Places'),
'description': _('Maximum number of decimal places to display when rendering pricing data'),
'default': 6,
'validator': [
int,
@ -1222,7 +1263,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Stock Item Pricing Age'),
'description': _('Exclude stock items older than this number of days from pricing calculations'),
'default': 0,
'units': 'days',
'units': _('days'),
'validator': [
int,
MinValueValidator(0),
@ -1244,7 +1285,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'PRICING_UPDATE_DAYS': {
'name': _('Pricing Rebuild Time'),
'name': _('Pricing Rebuild Interval'),
'description': _('Number of days before part pricing is automatically updated'),
'units': _('days'),
'default': 30,
@ -1400,6 +1441,27 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'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': {
'name': _('Sales Order Reference Pattern'),
'description': _('Required pattern for generating Sales Order reference field'),
@ -1568,16 +1630,39 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
'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'
class Meta:
"""Meta options for InvenTreeSetting."""
verbose_name = "InvenTree Setting"
verbose_name_plural = "InvenTree Settings"
key = models.CharField(
max_length=50,
blank=False,
@ -1599,9 +1684,27 @@ class InvenTreeSetting(BaseInvenTreeSetting):
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):
"""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 = {
'HOMEPAGE_PART_STARRED': {
'name': _('Show subscribed parts'),
@ -1743,6 +1846,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'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": {
'name': _('Inline report display'),
'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': {
'name': _('Seach Supplier Parts'),
'name': _('Search Supplier Parts'),
'description': _('Display supplier parts in search preview window'),
'default': True,
'validator': bool,
@ -1848,6 +1958,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'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': {
'name': _('Search Preview Results'),
'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)]
},
'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': {
'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'),
@ -1900,7 +2038,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'DISPLAY_STOCKTAKE_TAB': {
'name': _('Part Stocktake'),
'description': _('Display part stocktake information'),
'description': _('Display part stocktake information (if stocktake functionality is enabled)'),
'default': True,
'validator': bool,
},
@ -1918,15 +2056,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
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(
max_length=50,
blank=False,

View File

@ -180,7 +180,7 @@ class MethodStorageClass:
Args:
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
# for testing selective loading is made available
@ -196,7 +196,7 @@ class MethodStorageClass:
filtered_list[ref] = item
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:
"""Returns all user settings for a specific user.
@ -305,6 +305,13 @@ class InvenTreeNotificationBodies:
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):
"""Send out a notification."""

View File

@ -77,8 +77,6 @@ class GlobalSettingsSerializer(SettingsSerializer):
class UserSettingsSerializer(SettingsSerializer):
"""Serializer for the InvenTreeUserSetting model."""
user = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
"""Meta options for UserSettingsSerializer."""
@ -97,6 +95,8 @@ class UserSettingsSerializer(SettingsSerializer):
'typ',
]
user = serializers.PrimaryKeyRelatedField(read_only=True)
class GenericReferencedSettingSerializer(SettingsSerializer):
"""Serializer for a GenericReferencedSetting model.
@ -140,28 +140,41 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
class NotificationMessageSerializer(InvenTreeModelSerializer):
"""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)
source = serializers.SerializerMethodField(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()
def get_target(self, obj):
"""Function to resolve generic object reference to target."""
target = get_objectreference(obj, 'target_content_type', 'target_object_id')
if target and 'link' not in target:
@ -184,30 +197,10 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
"""Function to resolve generic object reference to source."""
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):
"""Serializer for the NewsFeedEntry model."""
read = serializers.BooleanField()
class Meta:
"""Meta options for NewsFeedEntrySerializer."""
@ -223,6 +216,8 @@ class NewsFeedEntrySerializer(InvenTreeModelSerializer):
'read',
]
read = serializers.BooleanField()
class ConfigSerializer(serializers.Serializer):
"""Serializer for the InvenTree configuration.

View File

@ -1,6 +1,7 @@
"""Tests for mechanisms in common."""
import json
import time
from datetime import timedelta
from http import HTTPStatus
@ -921,7 +922,16 @@ class CurrencyAPITests(InvenTreeAPITestCase):
# Delete any existing exchange rate data
Rate.objects.all().delete()
# 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
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 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_name = Field(attribute='part__full_name', readonly=True)
@ -49,13 +56,6 @@ class SupplierPartResource(InvenTreeResource):
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):
"""Inline for supplier-part pricing"""
@ -87,6 +87,13 @@ class SupplierPartAdmin(ImportExportModelAdmin):
class ManufacturerPartResource(InvenTreeResource):
"""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_name = Field(attribute='part__full_name', readonly=True)
@ -95,13 +102,6 @@ class ManufacturerPartResource(InvenTreeResource):
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):
"""Admin class for ManufacturerPart model."""
@ -157,6 +157,13 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
class SupplierPriceBreakResource(InvenTreeResource):
"""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))
supplier_id = Field(attribute='part__supplier__pk', readonly=True)
@ -169,13 +176,6 @@ class SupplierPriceBreakResource(InvenTreeResource):
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):
"""Admin class for the SupplierPriceBreak model"""

View File

@ -1,24 +1,24 @@
"""Provides a JSON API for the Company app."""
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.rest_framework import DjangoFilterBackend
from rest_framework import filters
import part.models
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.api import (AttachmentMixin, ListCreateDestroyAPIView,
MetadataView)
from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS)
from InvenTree.helpers import str2bool
from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI)
from plugin.serializers import MetadataSerializer
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
ManufacturerPartParameter, SupplierPart,
SupplierPriceBreak)
from .serializers import (CompanySerializer,
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
ContactSerializer,
ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer,
ManufacturerPartSerializer, SupplierPartSerializer,
@ -44,11 +44,7 @@ class CompanyList(ListCreateAPI):
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [
'is_customer',
@ -86,14 +82,57 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
return queryset
class CompanyMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating Company metadata."""
class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for the CompanyAttachment model"""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance for a Company"""
return MetadataSerializer(Company, *args, **kwargs)
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
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):
@ -145,11 +184,7 @@ class ManufacturerPartList(ListCreateDestroyAPIView):
return self.serializer_class(*args, **kwargs)
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_backends = SEARCH_ORDER_FILTER
search_fields = [
'manufacturer__name',
@ -195,11 +230,30 @@ class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI
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):
"""API endpoint for list view of ManufacturerPartParamater model."""
queryset = ManufacturerPartParameter.objects.all()
serializer_class = ManufacturerPartParameterSerializer
filterset_class = ManufacturerPartParameterFilter
def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint"""
@ -221,38 +275,7 @@ class ManufacturerPartParameterList(ListCreateDestroyAPIView):
return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset):
"""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',
]
filter_backends = SEARCH_ORDER_FILTER
search_fields = [
'name',
@ -349,11 +372,7 @@ class SupplierPartList(ListCreateDestroyAPIView):
serializer_class = SupplierPartSerializer
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
InvenTreeOrderingFilter,
]
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = [
'SKU',
@ -405,6 +424,15 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI):
class SupplierPriceBreakFilter(rest_filters.FilterSet):
"""Custom API filters for the SupplierPriceBreak list endpoint"""
class Meta:
"""Metaclass options"""
model = SupplierPriceBreak
fields = [
'part',
'quantity',
]
base_part = rest_filters.ModelChoiceFilter(
label='Base Part',
queryset=part.models.Part.objects.all(),
@ -417,15 +445,6 @@ class SupplierPriceBreakFilter(rest_filters.FilterSet):
field_name='part__supplier',
)
class Meta:
"""Metaclass options"""
model = SupplierPriceBreak
fields = [
'part',
'quantity',
]
class SupplierPriceBreakList(ListCreateAPI):
"""API endpoint for list view of SupplierPriceBreak object.
@ -454,10 +473,7 @@ class SupplierPriceBreakList(ListCreateAPI):
return self.serializer_class(*args, **kwargs)
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
filter_backends = ORDER_FILTER
ordering_fields = [
'quantity',
@ -477,18 +493,21 @@ manufacturer_part_api_urls = [
# Base URL for ManufacturerPartAttachment API endpoints
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'^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
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
re_path(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'),
@ -497,7 +516,10 @@ manufacturer_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
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'^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'^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'),
]

View File

@ -23,7 +23,7 @@ def migrate_currencies(apps, schema_editor):
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
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
"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the Company model"""
return reverse('api-company-list')
class Meta:
"""Metaclass defines extra model options"""
ordering = ['name', ]
@ -94,6 +89,11 @@ class Company(MetadataMixin, models.Model):
]
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,
help_text=_('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()
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):
"""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
"""
@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',
on_delete=models.CASCADE)
@ -228,7 +252,7 @@ class Contact(models.Model):
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.
Attributes:
@ -239,15 +263,15 @@ class ManufacturerPart(models.Model):
description: Descriptive notes field
"""
class Meta:
"""Metaclass defines extra model options"""
unique_together = ('part', 'manufacturer', 'MPN')
@staticmethod
def get_api_url():
"""Return the API URL associated with the ManufacturerPart instance"""
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,
related_name='manufacturer_parts',
verbose_name=_('Base Part'),
@ -341,15 +365,15 @@ class ManufacturerPartParameter(models.Model):
Each parameter is a simple string (text) value.
"""
class Meta:
"""Metaclass defines extra model options"""
unique_together = ('manufacturer_part', 'name')
@staticmethod
def get_api_url():
"""Return the API URL associated with the ManufacturerPartParameter model"""
return reverse('api-manufacturer-part-parameter-list')
class Meta:
"""Metaclass defines extra model options"""
unique_together = ('manufacturer_part', 'name')
manufacturer_part = models.ForeignKey(
ManufacturerPart,
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.
Attributes:
@ -415,6 +439,13 @@ class SupplierPart(InvenTreeBarcodeMixin, common.models.MetaMixin):
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()
@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):
"""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)
"""
@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:
"""Metaclass defines extra model options"""
unique_together = ("part", "quantity")
@ -695,6 +712,13 @@ class SupplierPriceBreak(common.models.PriceBreak):
"""Format a string representation of a SupplierPriceBreak instance"""
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')
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 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')
@ -713,4 +737,4 @@ def after_delete_supplier_price(sender, instance, **kwargs):
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
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
import part.filters
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer, RemoteImageMixin)
from part.serializers import PartBriefSerializer
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
ManufacturerPartParameter, SupplierPart,
SupplierPriceBreak)
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
class CompanyBriefSerializer(InvenTreeModelSerializer):
"""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:
"""Metaclass options."""
@ -41,39 +37,14 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
'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):
"""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:
"""Metaclass options."""
@ -101,6 +72,29 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
'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):
"""Save the Company instance"""
super().save()
@ -126,14 +120,53 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
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):
"""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)
pretty_name = serializers.CharField(read_only=True)
model = ManufacturerPart
fields = [
'pk',
'part',
'part_detail',
'pretty_name',
'manufacturer',
'manufacturer_detail',
'description',
'MPN',
'link',
]
def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required"""
@ -152,24 +185,14 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
if prettify is not True:
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))
class Meta:
"""Metaclass options."""
model = ManufacturerPart
fields = [
'pk',
'part',
'part_detail',
'pretty_name',
'manufacturer',
'manufacturer_detail',
'description',
'MPN',
'link',
]
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the ManufacturerPartAttachment class."""
@ -179,37 +202,14 @@ class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
model = ManufacturerPartAttachment
fields = [
'pk',
fields = InvenTreeAttachmentSerializer.attachment_fields([
'manufacturer_part',
'attachment',
'filename',
'link',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [
'upload_date',
]
])
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""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:
"""Metaclass options."""
@ -224,66 +224,20 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
'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):
"""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)
man_detail = kwargs.pop('manufacturer_part_detail', 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')
if not man_detail:
self.fields.pop('manufacturer_part_detail')
if prettify is not True:
self.fields.pop('pretty_name')
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
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)
class SupplierPartSerializer(InvenTreeModelSerializer):
"""Serializer for SupplierPart object."""
class Meta:
"""Metaclass options."""
@ -320,6 +274,63 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'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
def annotate_queryset(queryset):
"""Annotate the SupplierPart queryset with extra fields:
@ -375,6 +386,22 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
"""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):
"""Initialize this serializer with extra fields as required"""
@ -397,11 +424,7 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
label=_('Price'),
)
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
)
price_currency = InvenTreeCurrencySerializer()
supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True)
@ -409,19 +432,3 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
# Detail serializer for SupplierPart
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" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block sidebar %}
{% include 'company/sidebar.html' %}
@ -137,6 +138,8 @@
</div>
</div>
{% if company.is_customer %}
{% if roles.sales_order.view %}
<div class='panel panel-hidden' id='panel-sales-orders'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@ -162,7 +165,9 @@
</table>
</div>
</div>
{% endif %}
{% if roles.stock.view %}
<div class='panel panel-hidden' id='panel-assigned-stock'>
<div class='panel-heading'>
<h4>{% trans "Assigned Stock" %}</h4>
@ -175,9 +180,40 @@
</div>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
</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-heading'>
@ -194,11 +230,86 @@
</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 %}
{% block js_ready %}
{{ 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() {
setupNotesField(
@ -207,18 +318,7 @@
{
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() {
@ -239,20 +339,65 @@
});
{% 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() {
{% if roles.sales_order.view %}
loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
params: {
customer: {{ company.id }},
}
});
{% endif %}
{% if roles.salse_order.add %}
$("#new-sales-order").click(function() {
createSalesOrder({
customer: {{ company.pk }},
});
});
{% endif %}
});
{% endif %}
@ -291,7 +436,7 @@
createManufacturerPart({
manufacturer: {{ company.pk }},
onSuccess: function() {
$("#part-table").bootstrapTable("refresh");
$("#part-table").bootstrapTable('refresh');
}
});
});
@ -313,7 +458,7 @@
deleteManufacturerParts(selections, {
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() {
constructForm('{% url "api-manufacturer-part-parameter-list" %}', {
@ -243,7 +225,7 @@ $('#parameter-create').click(function() {
}
},
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" %}
{% endif %}
{% if company.is_customer %}
{% if roles.sales_order.view %}
{% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %}
{% endif %}
{% if roles.stock.view %}
{% trans "Assigned Stock Items" as text %}
{% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %}
{% 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 %}
{% 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>
</tr>
{% endif %}
{% if part.barcode_hash %}
<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 %}
{% include "barcode_data.html" with instance=part %}
</table>
{% endblock details %}
@ -270,10 +264,10 @@ src="{% static 'img/blank_image.png' %}"
{% if barcodes %}
$("#show-qr-code").click(function() {
launchModalForm("{% url 'supplier-part-qr' part.pk %}",
{
no_post: true,
});
showQRDialog(
'{% trans "Supplier Part QR Code" %}',
'{"supplierpart": {{ part.pk }}}'
);
});
$("#barcode-link").click(function() {
@ -299,27 +293,12 @@ loadSupplierPriceBreakTable({
});
$('#new-price-break').click(function() {
constructForm(
'{% url "api-part-supplier-price-list" %}',
{
method: 'POST',
fields: {
quantity: {},
part: {
value: {{ part.pk }},
hidden: true,
},
price: {},
price_currency: {
},
},
title: '{% trans "Add Price Break" %}',
createSupplierPartPriceBreak({{ part.pk }}, {
onSuccess: function() {
$("#price-break-table").bootstrapTable("refresh");
$("#price-break-table").bootstrapTable('refresh');
}
}
);
});
});
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 .models import Company, SupplierPart
from .models import Company, Contact, SupplierPart
class CompanyTest(InvenTreeAPITestCase):
@ -17,11 +17,14 @@ class CompanyTest(InvenTreeAPITestCase):
'purchase_order.change',
]
def setUp(self):
@classmethod
def setUpTestData(cls):
"""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='Sippy Cup Emporium', description='Another supplier')
@ -137,6 +140,144 @@ class CompanyTest(InvenTreeAPITestCase):
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):
"""Series of tests for the Manufacturer DRF API."""

View File

@ -26,8 +26,12 @@ class CompanySimpleTest(TestCase):
'price_breaks',
]
def setUp(self):
@classmethod
def setUpTestData(cls):
"""Perform initialization for the tests in this class"""
super().setUpTestData()
Company.objects.create(name='ABC Co.',
description='Seller of ABC products',
website='www.abc-sales.com',
@ -35,10 +39,10 @@ class CompanySimpleTest(TestCase):
is_customer=False,
is_supplier=True)
self.acme0001 = SupplierPart.objects.get(SKU='ACME0001')
self.acme0002 = SupplierPart.objects.get(SKU='ACME0002')
self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312')
cls.acme0001 = SupplierPart.objects.get(SKU='ACME0001')
cls.acme0002 = SupplierPart.objects.get(SKU='ACME0002')
cls.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
cls.zergm312 = SupplierPart.objects.get(SKU='ZERGM312')
def test_company_model(self):
"""Tests for the company model data"""
@ -128,6 +132,23 @@ class CompanySimpleTest(TestCase):
with self.assertRaises(ValidationError):
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):
"""Unit tests for the Contact model"""
@ -201,3 +222,21 @@ class ManufacturerPartSimpleTest(TestCase):
Part.objects.get(pk=self.part.id).delete()
# Check that ManufacturerPart was deleted
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."""
from django.urls import include, re_path
from django.urls import include, path, re_path
from . import views
company_urls = [
# 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'),
])),
@ -21,12 +21,11 @@ company_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 = [
re_path(r'^(?P<pk>\d+)/', include([
re_path('^qr_code/?', views.SupplierPartQRCode.as_view(), name='supplier-part-qr'),
path(r'<int:pk>/', include([
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.views.generic import DetailView, ListView
from InvenTree.views import InvenTreeRoleMixin, QRCodeView
from InvenTree.views import InvenTreeRoleMixin
from plugin.views import InvenTreePluginViewMixin
from .models import Company, ManufacturerPart, SupplierPart
@ -112,18 +112,3 @@ class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
context_object_name = 'part'
queryset = SupplierPart.objects.all()
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_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)
# 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!)

View File

@ -3,14 +3,17 @@
from django.conf import settings
from django.core.exceptions import FieldError, ValidationError
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 rest_framework import filters
from rest_framework.exceptions import NotFound
import common.models
import InvenTree.helpers
from InvenTree.api import MetadataView
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from InvenTree.tasks import offload_task
from part.models import Part
@ -45,7 +48,7 @@ class LabelFilterMixin:
ids = []
# 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[]']]:
if ids := self.request.query_params.getlist(k, []):
# Return the first list of matches
@ -121,7 +124,7 @@ class LabelListView(LabelFilterMixin, ListAPI):
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter
InvenTreeSearchFilter
]
filterset_fields = [
@ -134,9 +137,15 @@ class LabelListView(LabelFilterMixin, ListAPI):
]
@method_decorator(cache_page(5), name='dispatch')
class LabelPrintMixin(LabelFilterMixin):
"""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):
"""Perform a GET request against this endpoint to print labels"""
return self.print(request, self.get_items())
@ -185,7 +194,7 @@ class LabelPrintMixin(LabelFilterMixin):
outputs = []
# 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"
@ -260,7 +269,7 @@ class LabelPrintMixin(LabelFilterMixin):
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(
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.
Filterable by:
@ -279,32 +298,30 @@ class StockItemLabelList(LabelListView):
- item: Filter by single stock item
- items: Filter by list of stock items
"""
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
ITEM_MODEL = StockItem
ITEM_KEY = 'item'
pass
class StockItemLabelDetail(RetrieveUpdateDestroyAPI):
class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockItemLabel object."""
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
pass
class StockItemLabelPrint(LabelPrintMixin, RetrieveAPI):
class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockItemLabel object."""
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
ITEM_MODEL = StockItem
ITEM_KEY = 'item'
pass
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.
Filterable by:
@ -313,56 +330,41 @@ class StockLocationLabelList(LabelListView):
- location: Filter by a single stock location
- locations: Filter by list of stock locations
"""
queryset = StockLocationLabel.objects.all()
serializer_class = StockLocationLabelSerializer
ITEM_MODEL = StockLocation
ITEM_KEY = 'location'
pass
class StockLocationLabelDetail(RetrieveUpdateDestroyAPI):
class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockLocationLabel object."""
queryset = StockLocationLabel.objects.all()
serializer_class = StockLocationLabelSerializer
pass
class StockLocationLabelPrint(LabelPrintMixin, RetrieveAPI):
class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockLocationLabel object."""
queryset = StockLocationLabel.objects.all()
seiralizer_class = StockLocationLabelSerializer
ITEM_MODEL = StockLocation
ITEM_KEY = 'location'
pass
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."""
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
ITEM_MODEL = Part
ITEM_KEY = 'part'
pass
class PartLabelDetail(RetrieveUpdateDestroyAPI):
class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single PartLabel object."""
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
pass
class PartLabelPrint(LabelPrintMixin, RetrieveAPI):
class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a PartLabel object."""
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
ITEM_MODEL = Part
ITEM_KEY = 'part'
pass
label_api_urls = [
@ -370,8 +372,9 @@ label_api_urls = [
# Stock item labels
re_path(r'stock/', include([
# 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'metadata/', MetadataView.as_view(), {'model': StockItemLabel}, name='api-stockitem-label-metadata'),
re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
])),
@ -382,8 +385,9 @@ label_api_urls = [
# Stock location labels
re_path(r'location/', include([
# 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'metadata/', MetadataView.as_view(), {'model': StockLocationLabel}, name='api-stocklocation-label-metadata'),
re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
])),
@ -394,8 +398,9 @@ label_api_urls = [
# Part labels
re_path(r'^part/', include([
# 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'^metadata/', MetadataView.as_view(), {'model': PartLabel}, name='api-part-label-metadata'),
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 stock.models
from InvenTree.helpers import normalize, validateFilterString
from plugin.models import MetadataMixin
try:
from django_weasyprint import WeasyTemplateResponseMixin
@ -70,7 +71,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(models.Model):
class LabelTemplate(MetadataMixin, models.Model):
"""Base class for generic, filterable labels."""
class Meta:

View File

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

View File

@ -27,9 +27,10 @@ class LabelTest(InvenTreeAPITestCase):
'stock'
]
def setUp(self) -> None:
@classmethod
def setUpTestData(cls):
"""Ensure that some label instances exist as part of init routine"""
super().setUp()
super().setUpTestData()
apps.get_app_config('label').create_labels()
def test_default_labels(self):
@ -129,3 +130,21 @@ class LabelTest(InvenTreeAPITestCase):
self.assertIn("http://testserver/part/1/", content)
self.assertIn("image: /static/img/blank_image.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