2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-06 20:11:37 +00:00

Merge branch 'master' into pui-plugins

This commit is contained in:
Oliver Walters
2024-08-11 06:23:51 +00:00
630 changed files with 300583 additions and 174725 deletions
.devcontainer
.github
.gitignore.pkgr.yml.pre-commit-config.yaml
.vscode
CONTRIBUTING.mdREADME.mdcodecov.yml
contrib
docs
pyproject.tomlreadthedocs.yml
src
backend
InvenTree
InvenTree
build
common
company
config_template.yaml
generic
states
importer
locale
ar
LC_MESSAGES
bg
LC_MESSAGES
cs
LC_MESSAGES
da
LC_MESSAGES
de
LC_MESSAGES
el
LC_MESSAGES
en
LC_MESSAGES
es
LC_MESSAGES
es_MX
LC_MESSAGES
et
LC_MESSAGES
fa
LC_MESSAGES
fi
LC_MESSAGES
fr
LC_MESSAGES
he
LC_MESSAGES
hi
LC_MESSAGES
hu
LC_MESSAGES
id
LC_MESSAGES
it
LC_MESSAGES
ja
LC_MESSAGES
ko
LC_MESSAGES
lv
LC_MESSAGES
nl
LC_MESSAGES
no
LC_MESSAGES
pl
LC_MESSAGES
pt
LC_MESSAGES
ro
LC_MESSAGES
ru
LC_MESSAGES
sk
LC_MESSAGES
sl
LC_MESSAGES
sr
LC_MESSAGES
sv
LC_MESSAGES
th
LC_MESSAGES
tr
LC_MESSAGES
uk
LC_MESSAGES
vi
LC_MESSAGES
zh
LC_MESSAGES
zh_Hans
LC_MESSAGES
machine
order
part
plugin
report
script
stock
templates
users
package-lock.jsonpackage.jsonrequirements-dev.txtrequirements.inrequirements.txt
frontend
.linguircpackage.jsonplaywright.config.ts
src
App.tsx
components
contexts
defaults
enums
forms
functions
hooks
locales
main.tsx
pages
router.tsx
states
tables
tests
yarn.lock
tasks.py

@@ -8,7 +8,6 @@ from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.http import JsonResponse
from django.urls import include, path
from django.utils.translation import gettext_lazy as _
from django_q.models import OrmQ
@@ -21,9 +20,7 @@ from rest_framework.views import APIView
import InvenTree.version
import users.models
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI
from InvenTree.permissions import RolePermission
from InvenTree.templatetags.inventree_extras import plugins_info
from part.models import Part
from plugin.serializers import MetadataSerializer
@@ -311,8 +308,26 @@ class BulkDeleteMixin:
- Speed (single API call and DB query)
"""
def validate_delete(self, queryset, request) -> None:
"""Perform validation right before deletion.
Arguments:
queryset: The queryset to be deleted
request: The request object
Returns:
None
Raises:
ValidationError: If the deletion should not proceed
"""
pass
def filter_delete_queryset(self, queryset, request):
"""Provide custom filtering for the queryset *before* it is deleted."""
"""Provide custom filtering for the queryset *before* it is deleted.
The default implementation does nothing, just returns the queryset.
"""
return queryset
def delete(self, request, *args, **kwargs):
@@ -371,6 +386,9 @@ class BulkDeleteMixin:
if filters:
queryset = queryset.filter(**filters)
# Run a final validation step (should raise an error if the deletion should not proceed)
self.validate_delete(queryset, request)
n_deleted = queryset.count()
queryset.delete()
@@ -383,58 +401,6 @@ class ListCreateDestroyAPIView(BulkDeleteMixin, ListCreateAPI):
...
class APIDownloadMixin:
"""Mixin for enabling a LIST endpoint to be downloaded a file.
To download the data, add the ?export=<fmt> to the query string.
The implementing class must provided a download_queryset method,
e.g.
def download_queryset(self, queryset, export_format):
dataset = StockItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
return DownloadFile(filedata, filename)
"""
def get(self, request, *args, **kwargs):
"""Generic handler for a download request."""
export_format = request.query_params.get('export', None)
if export_format and export_format in ['csv', 'tsv', 'xls', 'xlsx']:
queryset = self.filter_queryset(self.get_queryset())
return self.download_queryset(queryset, export_format)
# Default to the parent class implementation
return super().get(request, *args, **kwargs)
def download_queryset(self, queryset, export_format):
"""This function must be implemented to provide a downloadFile request."""
raise NotImplementedError('download_queryset method not implemented!')
class AttachmentMixin:
"""Mixin for creating attachment objects, and ensuring the user information is saved correctly."""
permission_classes = [permissions.IsAuthenticated, RolePermission]
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['attachment', 'comment', 'link']
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 APISearchViewSerializer(serializers.Serializer):
"""Serializer for the APISearchView."""

@@ -1,11 +1,115 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 206
INVENTREE_API_VERSION = 236
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v236 - 2024-08-10 : https://github.com/inventree/InvenTree/pull/7844
- Adds "supplier_name" to the PurchaseOrder API serializer
v235 - 2024-08-08 : https://github.com/inventree/InvenTree/pull/7837
- Adds "on_order" quantity to SalesOrderLineItem serializer
- Adds "building" quantity to SalesOrderLineItem serializer
v234 - 2024-08-08 : https://github.com/inventree/InvenTree/pull/7829
- Fixes bug in the plugin metadata endpoint
v233 - 2024-08-04 : https://github.com/inventree/InvenTree/pull/7807
- Adds new endpoints for managing state of build orders
- Adds new endpoints for managing state of purchase orders
- Adds new endpoints for managing state of sales orders
- Adds new endpoints for managing state of return orders
v232 - 2024-08-03 : https://github.com/inventree/InvenTree/pull/7793
- Allow ordering of SalesOrderShipment API by 'shipment_date' and 'delivery_date'
v231 - 2024-08-03 : https://github.com/inventree/InvenTree/pull/7794
- Optimize BuildItem and BuildLine serializers to improve API efficiency
v230 - 2024-05-05 : https://github.com/inventree/InvenTree/pull/7164
- Adds test statistics endpoint
v229 - 2024-07-31 : https://github.com/inventree/InvenTree/pull/7775
- Add extra exportable fields to the BomItem serializer
v228 - 2024-07-18 : https://github.com/inventree/InvenTree/pull/7684
- Adds "icon" field to the PartCategory.path and StockLocation.path API
- Adds icon packages API endpoint
v227 - 2024-07-19 : https://github.com/inventree/InvenTree/pull/7693/
- Adds endpoints to list and revoke the tokens issued to the current user
v226 - 2024-07-15 : https://github.com/inventree/InvenTree/pull/7648
- Adds barcode generation API endpoint
v225 - 2024-07-17 : https://github.com/inventree/InvenTree/pull/7671
- Adds "filters" field to DataImportSession API
v224 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7667
- Add notes field to ManufacturerPart and SupplierPart API endpoints
v223 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7649
- Allow adjustment of "packaging" field when receiving items against a purchase order
v222 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7635
- Adjust the BomItem API endpoint to improve data import process
v221 - 2024-07-13 : https://github.com/inventree/InvenTree/pull/7636
- Adds missing fields from StockItemBriefSerializer
- Adds missing fields from PartBriefSerializer
- Adds extra exportable fields to BuildItemSerializer
v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585
- Adds "revision_of" field to Part serializer
- Adds new API filters for "revision" status
v219 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7611
- Adds new fields to the BuildItem API endpoints
- Adds new ordering / filtering options to the BuildItem API endpoints
v218 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7619
- Adds "can_build" field to the BomItem API
v217 - 2024-07-09 : https://github.com/inventree/InvenTree/pull/7599
- Fixes bug in "project_code" field for order API endpoints
v216 - 2024-07-08 : https://github.com/inventree/InvenTree/pull/7595
- Moves API endpoint for contenttype lookup by model name
v215 - 2024-07-09 : https://github.com/inventree/InvenTree/pull/7591
- Adds additional fields to the BuildLine serializer
v214 - 2024-07-08 : https://github.com/inventree/InvenTree/pull/7587
- Adds "default_location_detail" field to the Part API
v213 - 2024-07-06 : https://github.com/inventree/InvenTree/pull/7527
- Adds 'locked' field to Part API
v212 - 2024-07-06 : https://github.com/inventree/InvenTree/pull/7562
- Makes API generation more robust (no functional changes)
v211 - 2024-06-26 : https://github.com/inventree/InvenTree/pull/6911
- Adds API endpoints for managing data import and export
v210 - 2024-06-26 : https://github.com/inventree/InvenTree/pull/7518
- Adds translateable text to User API fields
v209 - 2024-06-26 : https://github.com/inventree/InvenTree/pull/7514
- Add "top_level" filter to PartCategory API endpoint
- Add "top_level" filter to StockLocation API endpoint
v208 - 2024-06-19 : https://github.com/inventree/InvenTree/pull/7479
- Adds documentation for the user roles API endpoint (no functional changes)
v207 - 2024-06-09 : https://github.com/inventree/InvenTree/pull/7420
- Moves all "Attachment" models into a single table
- All "Attachment" operations are now performed at /api/attachment/
- Add permissions information to /api/user/roles/ endpoint
v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417
- Adds "choices" field to the PartTestTemplate model
@@ -111,7 +215,7 @@ v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604
- Adds "external_stock" field to the Part API endpoint
- Adds "external_stock" field to the BomItem API endpoint
- Adds "external_stock" field to the BuildLine API endpoint
- Stock quantites represented in the BuildLine API endpoint are now filtered by Build.source_location
- Stock quantities represented in the BuildLine API endpoint are now filtered by Build.source_location
v177 - 2024-02-27 : https://github.com/inventree/InvenTree/pull/6581
- Adds "subcategoies" count to PartCategoryTree serializer

@@ -11,6 +11,8 @@ from django.core.exceptions import AppRegistryNotReady
from django.db import transaction
from django.db.utils import IntegrityError, OperationalError
from allauth.socialaccount.signals import social_account_updated
import InvenTree.conversion
import InvenTree.ready
import InvenTree.tasks
@@ -70,6 +72,12 @@ class InvenTreeConfig(AppConfig):
self.add_user_on_startup()
self.add_user_from_file()
# register event receiver and connect signal for SSO group sync. The connected signal is
# used for account updates whereas the receiver is used for the initial account creation.
from InvenTree import sso
social_account_updated.connect(sso.ensure_sso_groups)
def remove_obsolete_tasks(self):
"""Delete any obsolete scheduled tasks in the database."""
obsolete = [

@@ -153,7 +153,7 @@ def convert_physical_value(value: str, unit: str = None, strip_units=True):
if unit:
try:
valid = unit in ureg
except Exception as exc:
except Exception:
valid = False
if not valid:
@@ -196,7 +196,7 @@ def convert_physical_value(value: str, unit: str = None, strip_units=True):
try:
value = convert_value(attempt, unit)
break
except Exception as exc:
except Exception:
value = None
if value is None:

@@ -9,7 +9,6 @@ import traceback
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.utils import IntegrityError, OperationalError
from django.utils.translation import gettext_lazy as _
import rest_framework.views as drfviews

@@ -8,6 +8,7 @@ from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from common.currency import currency_code_default, currency_codes
from common.settings import get_global_setting
logger = logging.getLogger('inventree')
@@ -22,14 +23,13 @@ class InvenTreeExchange(SimpleExchangeBackend):
def get_rates(self, **kwargs) -> dict:
"""Set the requested currency codes and get rates."""
from common.models import InvenTreeSetting
from plugin import registry
base_currency = kwargs.get('base_currency', currency_code_default())
symbols = kwargs.get('symbols', currency_codes())
# Find the selected exchange rate plugin
slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False)
slug = get_global_setting('CURRENCY_UPDATE_PLUGIN', create=False)
if slug:
plugin = registry.get_plugin(slug)

@@ -33,7 +33,7 @@ class InvenTreeRestURLField(RestURLField):
def run_validation(self, data=empty):
"""Override default validation behaviour for this field type."""
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', True, cache=False)
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
if not strict_urls and data is not empty and '://' not in data:
# Validate as if there were a schema provided

@@ -15,6 +15,7 @@ from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter
from allauth_2fa.forms import TOTPDeviceForm
from allauth_2fa.utils import user_has_valid_totp_device
from crispy_forms.bootstrap import AppendedText, PrependedAppendedText, PrependedText
from crispy_forms.helper import FormHelper
@@ -190,7 +191,7 @@ class CustomSignupForm(SignupForm):
# check for two password fields
if not get_global_setting('LOGIN_SIGNUP_PWD_TWICE'):
self.fields.pop('password2')
self.fields.pop('password2', None)
# reorder fields
set_form_field_order(
@@ -211,6 +212,16 @@ class CustomSignupForm(SignupForm):
return cleaned_data
class CustomTOTPDeviceForm(TOTPDeviceForm):
"""Ensure that db registration is enabled."""
def __init__(self, user, metadata=None, **kwargs):
"""Override to check if registration is open."""
if not settings.MFA_ENABLED:
raise forms.ValidationError(_('MFA Registration is disabled.'))
super().__init__(user, metadata, **kwargs)
def registration_enabled():
"""Determine whether user registration is enabled."""
if get_global_setting('LOGIN_ENABLE_REG') or InvenTree.sso.registration_enabled():
@@ -269,7 +280,9 @@ class RegistratonMixin:
# Check if a default group is set in settings
start_group = get_global_setting('SIGNUP_GROUP')
if start_group:
if (
start_group and user.groups.count() == 0
): # check that no group has been added through SSO group sync
try:
group = Group.objects.get(id=start_group)
user.groups.add(group)

@@ -3,7 +3,6 @@
import datetime
import hashlib
import io
import json
import logging
import os
import os.path
@@ -27,7 +26,6 @@ from bleach import clean
from djmoney.money import Money
from PIL import Image
import InvenTree.version
from common.currency import currency_code_default
from .settings import MEDIA_URL, STATIC_URL
@@ -396,41 +394,9 @@ def WrapWithQuotes(text, quote='"'):
return text
def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs):
"""Generate a string for a barcode. Adds some global InvenTree parameters.
Args:
cls_name: string describing the object type e.g. 'StockItem'
object_pk (int): ID (Primary Key) of the object in the database
object_data: Python dict object containing extra data which will be rendered to string (must only contain stringable values)
Returns:
json string of the supplied data plus some other data
"""
if object_data is None:
object_data = {}
brief = kwargs.get('brief', True)
data = {}
if brief:
data[cls_name] = object_pk
else:
data['tool'] = 'InvenTree'
data['version'] = InvenTree.version.inventreeVersion()
data['instance'] = InvenTree.version.inventreeInstanceName()
# Ensure PK is included
object_data['id'] = object_pk
data[cls_name] = object_data
return str(json.dumps(data, sort_keys=True))
def GetExportFormats():
"""Return a list of allowable file formats for exporting data."""
return ['csv', 'tsv', 'xls', 'xlsx', 'json', 'yaml']
"""Return a list of allowable file formats for importing or exporting tabular data."""
return ['csv', 'xlsx', 'tsv', 'json']
def DownloadFile(

@@ -2,8 +2,10 @@
import inspect
from pathlib import Path
from typing import Any, Callable
from django.conf import settings
from django.core.cache import cache
from plugin import registry as plg_registry
@@ -104,3 +106,37 @@ class ClassProviderMixin:
except ValueError:
# Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir
return False
def get_shared_class_instance_state_mixin(get_state_key: Callable[[type], str]):
"""Get a mixin class that provides shared state for classes across the main application and worker.
Arguments:
get_state_key: A function that returns the key for the shared state when given a class instance.
"""
class SharedClassStateMixinClass:
"""Mixin to provide shared state for classes across the main application and worker."""
def set_shared_state(self, key: str, value: Any):
"""Set a shared state value for this machine.
Arguments:
key: The key for the shared state
value: The value to set
"""
cache.set(self._get_key(key), value, timeout=None)
def get_shared_state(self, key: str, default=None):
"""Get a shared state value for this machine.
Arguments:
key: The key for the shared state
"""
return cache.get(self._get_key(key)) or default
def _get_key(self, key: str):
"""Get the key for this class instance."""
return f'{get_state_key(self)}:{key}'
return SharedClassStateMixinClass

@@ -15,9 +15,6 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from PIL import Image
import InvenTree
import InvenTree.helpers_model
import InvenTree.version
from common.notifications import (
InvenTreeNotificationBodies,
NotificationBody,
@@ -39,8 +36,6 @@ def get_base_url(request=None):
3. If settings.SITE_URL is set (e.g. in the Django settings), use that
4. If the InvenTree setting INVENTREE_BASE_URL is set, use that
"""
import common.models
# Check if a request is provided
if request:
return request.build_absolute_uri('/')
@@ -107,8 +102,6 @@ def download_image_from_url(remote_url, timeout=2.5):
ValueError: Server responded with invalid 'Content-Length' value
TypeError: Response is not a valid image
"""
import common.models
# Check that the provided URL at least looks valid
validator = URLValidator()
validator(remote_url)
@@ -206,8 +199,6 @@ def render_currency(
max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
include_symbol: If True, include the currency symbol in the output
"""
import common.models
if money in [None, '']:
return '-'
@@ -252,7 +243,7 @@ def render_currency(
def getModelsWithMixin(mixin_class) -> list:
"""Return a list of models that inherit from the given mixin class.
"""Return a list of database models that inherit from the given mixin class.
Args:
mixin_class: The mixin class to search for
@@ -331,9 +322,7 @@ def notify_users(
'instance': instance,
'name': content.name.format(**content_context),
'message': content.message.format(**content_context),
'link': InvenTree.helpers_model.construct_absolute_url(
instance.get_absolute_url()
),
'link': construct_absolute_url(instance.get_absolute_url()),
'template': {'subject': content.name.format(**content_context)},
}

@@ -15,6 +15,7 @@ Additionally, update the following files with the new locale code:
from django.utils.translation import gettext_lazy as _
LOCALES = [
('ar', _('Arabic')),
('bg', _('Bulgarian')),
('cs', _('Czech')),
('da', _('Danish')),
@@ -23,6 +24,7 @@ LOCALES = [
('en', _('English')),
('es', _('Spanish')),
('es-mx', _('Spanish (Mexican)')),
('et', _('Estonian')),
('fa', _('Farsi / Persian')),
('fi', _('Finnish')),
('fr', _('French')),

@@ -0,0 +1,53 @@
"""Custom management command to export settings definitions.
This is used to generate a JSON file which contains all of the settings,
so that they can be introspected by the InvenTree documentation system.
This in turn allows settings to be documented in the InvenTree documentation,
without having to manually duplicate the information in multiple places.
"""
import json
from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""Extract settings information, and export to a JSON file."""
def add_arguments(self, parser):
"""Add custom arguments for this command."""
parser.add_argument(
'filename', type=str, help='Output filename for settings definitions'
)
def handle(self, *args, **kwargs):
"""Export settings information to a JSON file."""
from common.models import InvenTreeSetting, InvenTreeUserSetting
settings = {'global': {}, 'user': {}}
# Global settings
for key, setting in InvenTreeSetting.SETTINGS.items():
settings['global'][key] = {
'name': str(setting['name']),
'description': str(setting['description']),
'default': str(InvenTreeSetting.get_setting_default(key)),
'units': str(setting.get('units', '')),
}
# User settings
for key, setting in InvenTreeUserSetting.SETTINGS.items():
settings['user'][key] = {
'name': str(setting['name']),
'description': str(setting['description']),
'default': str(InvenTreeUserSetting.get_setting_default(key)),
'units': str(setting.get('units', '')),
}
filename = kwargs.get('filename', 'inventree_settings.json')
with open(filename, 'w') as f:
json.dump(settings, f, indent=4)
print(f"Exported InvenTree settings definitions to '{filename}'")

@@ -0,0 +1,192 @@
"""Custom management command to migrate the old FontAwesome icons."""
import json
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from django.db import models
from common.icons import validate_icon
from part.models import PartCategory
from stock.models import StockLocation, StockLocationType
class Command(BaseCommand):
"""Generate an icon map from the FontAwesome library to the new icon library."""
help = """Helper command to migrate the old FontAwesome icons to the new icon library."""
def add_arguments(self, parser):
"""Add the arguments."""
parser.add_argument(
'--output-file',
type=str,
help='Path to file to write generated icon map to',
)
parser.add_argument(
'--input-file', type=str, help='Path to file to read icon map from'
)
parser.add_argument(
'--include-items',
default=False,
action='store_true',
help='Include referenced inventree items in the output icon map (optional)',
)
parser.add_argument(
'--import-now',
default=False,
action='store_true',
help='CAUTION: If this flag is set, the icon map will be imported and the database will be touched',
)
def handle(self, *args, **kwargs):
"""Generate an icon map from the FontAwesome library to the new icon library."""
# Check for invalid combinations of arguments
if kwargs['output_file'] and kwargs['input_file']:
raise CommandError('Cannot specify both --input-file and --output-file')
if not kwargs['output_file'] and not kwargs['input_file']:
raise CommandError('Must specify either --input-file or --output-file')
if kwargs['include_items'] and not kwargs['output_file']:
raise CommandError(
'--include-items can only be used with an --output-file specified'
)
if kwargs['output_file'] and kwargs['import_now']:
raise CommandError(
'--import-now can only be used with an --input-file specified'
)
ICON_MODELS = [
(StockLocation, 'custom_icon'),
(StockLocationType, 'icon'),
(PartCategory, '_icon'),
]
def get_model_items_with_icons(model: models.Model, icon_field: str):
"""Return a list of models with icon fields."""
return model.objects.exclude(**{f'{icon_field}__isnull': True}).exclude(**{
f'{icon_field}__exact': ''
})
# Generate output icon map file
if kwargs['output_file']:
icons = {}
for model, icon_name in ICON_MODELS:
self.stdout.write(
f'Processing model {model.__name__} with icon field {icon_name}'
)
items = get_model_items_with_icons(model, icon_name)
for item in items:
icon = getattr(item, icon_name)
try:
validate_icon(icon)
continue # Skip if the icon is already valid
except ValidationError:
pass
if icon not in icons:
icons[icon] = {
**({'items': []} if kwargs['include_items'] else {}),
'new_icon': '',
}
if kwargs['include_items']:
icons[icon]['items'].append({
'model': model.__name__.lower(),
'id': item.id, # type: ignore
})
self.stdout.write(f'Writing icon map for {len(icons.keys())} icons')
with open(kwargs['output_file'], 'w') as f:
json.dump(icons, f, indent=2)
self.stdout.write(f'Icon map written to {kwargs["output_file"]}')
# Import icon map file
if kwargs['input_file']:
with open(kwargs['input_file'], 'r') as f:
icons = json.load(f)
self.stdout.write(f'Loaded icon map for {len(icons.keys())} icons')
self.stdout.write('Verifying icon map')
has_errors = False
# Verify that all new icons are valid icons
for old_icon, data in icons.items():
try:
validate_icon(data.get('new_icon', ''))
except ValidationError:
self.stdout.write(
f'[ERR] Invalid icon: "{old_icon}" -> "{data.get("new_icon", "")}'
)
has_errors = True
# Verify that all required items are provided in the icon map
for model, icon_name in ICON_MODELS:
self.stdout.write(
f'Processing model {model.__name__} with icon field {icon_name}'
)
items = get_model_items_with_icons(model, icon_name)
for item in items:
icon = getattr(item, icon_name)
try:
validate_icon(icon)
continue # Skip if the icon is already valid
except ValidationError:
pass
if icon not in icons:
self.stdout.write(
f' [ERR] Icon "{icon}" not found in icon map'
)
has_errors = True
# If there are errors, stop here
if has_errors:
self.stdout.write(
'[ERR] Icon map has errors, please fix them before continuing with importing'
)
return
# Import the icon map into the database if the flag is set
if kwargs['import_now']:
self.stdout.write('Start importing icons and updating database...')
cnt = 0
for model, icon_name in ICON_MODELS:
self.stdout.write(
f'Processing model {model.__name__} with icon field {icon_name}'
)
items = get_model_items_with_icons(model, icon_name)
for item in items:
icon = getattr(item, icon_name)
try:
validate_icon(icon)
continue # Skip if the icon is already valid
except ValidationError:
pass
setattr(item, icon_name, icons[icon]['new_icon'])
cnt += 1
item.save()
self.stdout.write(
f'Icon map successfully imported - changed {cnt} items'
)
self.stdout.write('Icons are now migrated')
else:
self.stdout.write('Icon map is valid and ready to be imported')
self.stdout.write(
'Run the command with --import-now to import the icon map and update the database'
)

@@ -115,9 +115,14 @@ class InvenTreeMetadata(SimpleMetadata):
return metadata
def override_value(self, field_name, field_value, model_value):
def override_value(self, field_name: str, field_key: str, field_value, model_value):
"""Override a value on the serializer with a matching value for the model.
Often, the serializer field will point to an underlying model field,
which contains extra information (which is translated already).
Rather than duplicating this information in the serializer, we can extract it from the model.
This is used to override the serializer values with model values,
if (and *only* if) the model value should take precedence.
@@ -125,17 +130,28 @@ class InvenTreeMetadata(SimpleMetadata):
- field_value is None
- model_value is callable, and field_value is not (this indicates that the model value is translated)
- model_value is not a string, and field_value is a string (this indicates that the model value is translated)
Arguments:
- field_name: The name of the field
- field_key: The property key to override
- field_value: The value of the field (if available)
- model_value: The equivalent value of the model (if available)
"""
if model_value and not field_value:
if field_value is None and model_value is not None:
return model_value
if field_value and not model_value:
if model_value is None and field_value is not None:
return field_value
# Callable values will be evaluated later
if callable(model_value) and not callable(field_value):
return model_value
if type(model_value) is not str and type(field_value) is str:
if callable(field_value) and not callable(model_value):
return field_value
# Prioritize translated text over raw string values
if type(field_value) is str and type(model_value) is not str:
return model_value
return field_value
@@ -144,6 +160,8 @@ class InvenTreeMetadata(SimpleMetadata):
"""Override get_serializer_info so that we can add 'default' values to any fields whose Meta.model specifies a default value."""
self.serializer = serializer
request = getattr(self, 'request', None)
serializer_info = super().get_serializer_info(serializer)
# Look for any dynamic fields which were not available when the serializer was instantiated
@@ -153,12 +171,19 @@ class InvenTreeMetadata(SimpleMetadata):
# Already know about this one
continue
if hasattr(serializer, field_name):
field = getattr(serializer, field_name)
if field := getattr(serializer, field_name, None):
serializer_info[field_name] = self.get_field_info(field)
model_class = None
# Extract read_only_fields and write_only_fields from the Meta class (if available)
if meta := getattr(serializer, 'Meta', None):
read_only_fields = getattr(meta, 'read_only_fields', [])
write_only_fields = getattr(meta, 'write_only_fields', [])
else:
read_only_fields = []
write_only_fields = []
# Attributes to copy extra attributes from the model to the field (if they don't exist)
# Note that the attributes may be named differently on the underlying model!
extra_attributes = {
@@ -172,16 +197,20 @@ class InvenTreeMetadata(SimpleMetadata):
model_fields = model_meta.get_field_info(model_class)
model_default_func = getattr(model_class, 'api_defaults', None)
if model_default_func:
model_default_values = model_class.api_defaults(self.request)
if model_default_func := getattr(model_class, 'api_defaults', None):
model_default_values = model_default_func(request=request) or {}
else:
model_default_values = {}
# Iterate through simple fields
for name, field in model_fields.fields.items():
if name in serializer_info.keys():
if name in read_only_fields:
serializer_info[name]['read_only'] = True
if name in write_only_fields:
serializer_info[name]['write_only'] = True
if field.has_default():
default = field.default
@@ -197,10 +226,12 @@ class InvenTreeMetadata(SimpleMetadata):
serializer_info[name]['default'] = model_default_values[name]
for field_key, model_key in extra_attributes.items():
field_value = serializer_info[name].get(field_key, None)
field_value = getattr(serializer.fields[name], field_key, None)
model_value = getattr(field, model_key, None)
if value := self.override_value(name, field_value, model_value):
if value := self.override_value(
name, field_key, field_value, model_value
):
serializer_info[name][field_key] = value
# Iterate through relations
@@ -213,6 +244,12 @@ class InvenTreeMetadata(SimpleMetadata):
# Ignore reverse relations
continue
if name in read_only_fields:
serializer_info[name]['read_only'] = True
if name in write_only_fields:
serializer_info[name]['write_only'] = True
# Extract and provide the "limit_choices_to" filters
# This is used to automatically filter AJAX requests
serializer_info[name]['filters'] = (
@@ -220,10 +257,12 @@ class InvenTreeMetadata(SimpleMetadata):
)
for field_key, model_key in extra_attributes.items():
field_value = serializer_info[name].get(field_key, None)
field_value = getattr(serializer.fields[name], field_key, None)
model_value = getattr(relation.model_field, model_key, None)
if value := self.override_value(name, field_value, model_value):
if value := self.override_value(
name, field_key, field_value, model_value
):
serializer_info[name][field_key] = value
if name in model_default_values:
@@ -241,7 +280,8 @@ class InvenTreeMetadata(SimpleMetadata):
if instance is None and model_class is not None:
# Attempt to find the instance based on kwargs lookup
kwargs = getattr(self.view, 'kwargs', None)
view = getattr(self, 'view', None)
kwargs = getattr(view, 'kwargs', None) if view else None
if kwargs:
pk = None
@@ -298,8 +338,10 @@ class InvenTreeMetadata(SimpleMetadata):
# Force non-nullable fields to read as "required"
# (even if there is a default value!)
if not field.allow_null and not (
hasattr(field, 'allow_blank') and field.allow_blank
if (
'required' not in field_info
and not field.allow_null
and not (hasattr(field, 'allow_blank') and field.allow_blank)
):
field_info['required'] = True
@@ -326,8 +368,11 @@ class InvenTreeMetadata(SimpleMetadata):
field_info['api_url'] = '/api/user/'
elif field_info['model'] == 'contenttype':
field_info['api_url'] = '/api/contenttype/'
else:
elif hasattr(model, 'get_api_url'):
field_info['api_url'] = model.get_api_url()
else:
logger.warning("'get_api_url' method not defined for %s", model)
field_info['api_url'] = getattr(model, 'api_url', None)
# Handle custom 'primary key' field
field_info['pk_field'] = getattr(field, 'pk_field', 'pk') or 'pk'

@@ -12,6 +12,7 @@ from django.urls import Resolver404, include, path, resolve, reverse_lazy
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting
from InvenTree.urls import frontendpatterns
from users.models import ApiToken
@@ -153,11 +154,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
def require_2fa(self, request):
"""Use setting to check if MFA should be enforced for frontend page."""
from common.models import InvenTreeSetting
try:
if url_matcher.resolve(request.path[1:]):
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')
return get_global_setting('LOGIN_ENFORCE_MFA')
except Resolver404:
pass
return False

@@ -1,13 +1,9 @@
"""Generic models which provide extra functionality over base Django model types."""
import logging
import os
from datetime import datetime
from io import BytesIO
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
@@ -20,11 +16,11 @@ from error_report.models import Error
from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey
import common.settings
import InvenTree.fields
import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree')
@@ -97,7 +93,7 @@ class PluginValidationMixin(DiffMixin):
return
except ValidationError as exc:
raise exc
except Exception as exc:
except Exception:
# Log the exception to the database
import InvenTree.exceptions
@@ -218,12 +214,15 @@ class MetadataMixin(models.Model):
self.save()
class DataImportMixin(object):
class DataImportMixin:
"""Model mixin class which provides support for 'data import' functionality.
Models which implement this mixin should provide information on the fields available for import
"""
# TODO: This mixin should be removed after https://github.com/inventree/InvenTree/pull/6911 is implemented
# TODO: This approach to data import functionality is *outdated*
# Define a map of fields available for import
IMPORT_FIELDS = {}
@@ -304,10 +303,7 @@ class ReferenceIndexingMixin(models.Model):
if cls.REFERENCE_PATTERN_SETTING is None:
return ''
# import at function level to prevent cyclic imports
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting(
return common.settings.get_global_setting(
cls.REFERENCE_PATTERN_SETTING, create=False
).strip()
@@ -503,207 +499,71 @@ class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
abstract = True
def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
Returns:
path to store file, format: '<subdir>/<id>/filename'
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)
class InvenTreeAttachment(InvenTreeModel):
class InvenTreeAttachmentMixin:
"""Provides an abstracted class for managing file attachments.
An attachment can be either an uploaded file, or an external URL
Links the implementing model to the common.models.Attachment table,
and provides the following methods:
Attributes:
attachment: Upload file
link: External URL
comment: String descriptor for the attachment
user: User associated with file upload
upload_date: Date the file was uploaded
- attachments: Return a queryset containing all attachments for this model
"""
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
def delete(self):
"""Handle the deletion of a model instance.
abstract = True
def getSubdir(self):
"""Return the subdirectory under which attachments should be stored.
Note: Re-implement this for each subclass of InvenTreeAttachment
Before deleting the model instance, delete any associated attachments.
"""
return 'attachments'
def save(self, *args, **kwargs):
"""Provide better validation error."""
# Either 'attachment' or 'link' must be specified!
if not self.attachment and not self.link:
raise ValidationError({
'attachment': _('Missing file'),
'link': _('Missing external link'),
})
if self.attachment and self.attachment.name.lower().endswith('.svg'):
self.attachment.file.file = self.clean_svg(self.attachment)
super().save(*args, **kwargs)
def clean_svg(self, field):
"""Sanitize SVG file before saving."""
cleaned = sanitize_svg(field.file.read())
return BytesIO(bytes(cleaned, 'utf8'))
def __str__(self):
"""Human name for attachment."""
if self.attachment is not None:
return os.path.basename(self.attachment.name)
return str(self.link)
attachment = models.FileField(
upload_to=rename_attachment,
verbose_name=_('Attachment'),
help_text=_('Select file to attach'),
blank=True,
null=True,
)
link = InvenTree.fields.InvenTreeURLField(
blank=True,
null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL'),
)
comment = models.CharField(
blank=True,
max_length=100,
verbose_name=_('Comment'),
help_text=_('File comment'),
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('User'),
help_text=_('User'),
)
upload_date = models.DateField(
auto_now_add=True, null=True, blank=True, verbose_name=_('upload date')
)
self.attachments.all().delete()
super().delete()
@property
def basename(self):
"""Base name/path for attachment."""
if self.attachment:
return os.path.basename(self.attachment.name)
return None
def attachments(self):
"""Return a queryset containing all attachments for this model."""
return self.attachments_for_model().filter(model_id=self.pk)
@basename.setter
def basename(self, fn):
"""Function to rename the attachment file.
@classmethod
def check_attachment_permission(cls, permission, user) -> bool:
"""Check if the user has permission to perform the specified action on the attachment.
- Filename cannot be empty
- Filename cannot contain illegal characters
- Filename must specify an extension
- Filename cannot match an existing file
The default implementation runs a permission check against *this* model class,
but this can be overridden in the implementing class if required.
Arguments:
permission: The permission to check (add / change / view / delete)
user: The user to check against
Returns:
bool: True if the user has permission, False otherwise
"""
fn = fn.strip()
perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}'
return user.has_perm(perm)
if len(fn) == 0:
raise ValidationError(_('Filename must not be empty'))
def attachments_for_model(self):
"""Return all attachments for this model class."""
from common.models import Attachment
attachment_dir = settings.MEDIA_ROOT.joinpath(self.getSubdir())
old_file = settings.MEDIA_ROOT.joinpath(self.attachment.name)
new_file = settings.MEDIA_ROOT.joinpath(self.getSubdir(), fn).resolve()
model_type = self.__class__.__name__.lower()
# Check that there are no directory tricks going on...
if new_file.parent != attachment_dir:
logger.error(
"Attempted to rename attachment outside valid directory: '%s'", new_file
)
raise ValidationError(_('Invalid attachment directory'))
return Attachment.objects.filter(model_type=model_type)
# Ignore further checks if the filename is not actually being renamed
if new_file == old_file:
return
def create_attachment(self, attachment=None, link=None, comment='', **kwargs):
"""Create an attachment / link for this model."""
from common.models import Attachment
forbidden = [
"'",
'"',
'#',
'@',
'!',
'&',
'^',
'<',
'>',
':',
';',
'/',
'\\',
'|',
'?',
'*',
'%',
'~',
'`',
]
kwargs['attachment'] = attachment
kwargs['link'] = link
kwargs['comment'] = comment
kwargs['model_type'] = self.__class__.__name__.lower()
kwargs['model_id'] = self.pk
for c in forbidden:
if c in fn:
raise ValidationError(_(f"Filename contains illegal character '{c}'"))
if len(fn.split('.')) < 2:
raise ValidationError(_('Filename missing extension'))
if not old_file.exists():
logger.error(
"Trying to rename attachment '%s' which does not exist", old_file
)
return
if new_file.exists():
raise ValidationError(_('Attachment with this filename already exists'))
try:
os.rename(old_file, new_file)
self.attachment.name = os.path.join(self.getSubdir(), fn)
self.save()
except Exception:
raise ValidationError(_('Error renaming file'))
def fully_qualified_url(self):
"""Return a 'fully qualified' URL for this attachment.
- If the attachment is a link to an external resource, return the link
- If the attachment is an uploaded file, return the fully qualified media URL
"""
if self.link:
return self.link
if self.attachment:
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
return InvenTree.helpers_model.construct_absolute_url(media_url)
return ''
Attachment.objects.create(**kwargs)
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
"""Provides an abstracted self-referencing tree model for data categories.
- Each Category has one parent Category, which can be blank (for a top-level Category).
- Each Category can have zero-or-more child Categor(y/ies)
- Each Category can have zero-or-more child Category(y/ies)
Attributes:
name: brief name
@@ -715,6 +575,9 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
# e.g. for StockLocation, this value is 'location'
ITEM_PARENT_KEY = None
# Extra fields to include in the get_path result. E.g. icon
EXTRA_PATH_FIELDS = []
class Meta:
"""Metaclass defines extra model properties."""
@@ -920,7 +783,7 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
on_delete=models.DO_NOTHING,
blank=True,
null=True,
verbose_name=_('parent'),
verbose_name='parent',
related_name='children',
)
@@ -1008,7 +871,14 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
name: <name>,
}
"""
return [{'pk': item.pk, 'name': item.name} for item in self.path]
return [
{
'pk': item.pk,
'name': item.name,
**{k: getattr(item, k, None) for k in self.EXTRA_PATH_FIELDS},
}
for item in self.path
]
def __str__(self):
"""String representation of a category is the full path to that category."""
@@ -1072,6 +942,8 @@ class InvenTreeBarcodeMixin(models.Model):
- barcode_data : Raw data associated with an assigned barcode
- barcode_hash : A 'hash' of the assigned barcode data used to improve matching
The barcode_model_type_code() classmethod must be implemented in the model class.
"""
class Meta:
@@ -1102,11 +974,25 @@ class InvenTreeBarcodeMixin(models.Model):
# By default, use the name of the class
return cls.__name__.lower()
@classmethod
def barcode_model_type_code(cls):
r"""Return a 'short' code for the model type.
This is used to generate a efficient QR code for the model type.
It is expected to match this pattern: [0-9A-Z $%*+-.\/:]{2}
Note: Due to the shape constrains (45**2=2025 different allowed codes)
this needs to be explicitly implemented in the model class to avoid collisions.
"""
raise NotImplementedError(
'barcode_model_type_code() must be implemented in the model class'
)
def format_barcode(self, **kwargs):
"""Return a JSON string for formatting a QR code for this model instance."""
return InvenTree.helpers.MakeBarcode(
self.__class__.barcode_model_type(), self.pk, **kwargs
)
from plugin.base.barcodes.helper import generate_barcode
return generate_barcode(self)
def format_matched_response(self):
"""Format a standard response for a matched barcode."""
@@ -1124,7 +1010,7 @@ class InvenTreeBarcodeMixin(models.Model):
@property
def barcode(self):
"""Format a minimal barcode string (e.g. for label printing)."""
return self.format_barcode(brief=True)
return self.format_barcode()
@classmethod
def lookup_barcode(cls, barcode_hash):

@@ -115,6 +115,7 @@ def canAppAccessDatabase(
'makemessages',
'compilemessages',
'spectactular',
'collectstatic',
]
if not allow_shell:
@@ -125,7 +126,7 @@ def canAppAccessDatabase(
excluded_commands.append('test')
if not allow_plugins:
excluded_commands.extend(['collectstatic', 'collectplugins'])
excluded_commands.extend(['collectplugins'])
for cmd in excluded_commands:
if cmd in sys.argv:

@@ -404,6 +404,17 @@ class UserSerializer(InvenTreeModelSerializer):
read_only_fields = ['username']
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user')
)
last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user')
)
email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user')
)
class ExendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
@@ -424,6 +435,16 @@ class ExendedUserSerializer(UserSerializer):
read_only_fields = UserSerializer.Meta.read_only_fields + ['groups']
is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions')
)
is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser')
)
is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active')
)
def validate(self, attrs):
"""Expanded validation for changing user role."""
# Check if is_staff or is_superuser is in attrs
@@ -509,43 +530,6 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
return os.path.join(str(settings.MEDIA_URL), str(value))
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
"""Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
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(required=False, allow_null=False)
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'), required=False, source='basename', allow_blank=False
)
upload_date = serializers.DateField(read_only=True)
class InvenTreeImageSerializerField(serializers.ImageField):
"""Custom image serializer.
@@ -872,7 +856,7 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
remote_image = serializers.URLField(
required=False,
allow_blank=False,
allow_blank=True,
write_only=True,
label=_('Remote Image'),
help_text=_('URL of remote image file'),

@@ -18,7 +18,6 @@ import django.conf.locale
import django.core.exceptions
from django.core.validators import URLValidator
from django.http import Http404
from django.utils.translation import gettext_lazy as _
import pytz
from dotenv import load_dotenv
@@ -198,6 +197,7 @@ INSTALLED_APPS = [
'stock.apps.StockConfig',
'users.apps.UsersConfig',
'machine.apps.MachineConfig',
'importer.apps.ImporterConfig',
'web',
'generic',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
@@ -296,10 +296,11 @@ ADMIN_SHELL_IMPORT_MODELS = False
if (
DEBUG
and INVENTREE_ADMIN_ENABLED
and not TESTING
and get_boolean_setting('INVENTREE_DEBUG_SHELL', 'debug_shell', False)
): # noqa
try:
import django_admin_shell
import django_admin_shell # noqa: F401
INSTALLED_APPS.append('django_admin_shell')
ADMIN_SHELL_ENABLE = True
@@ -1208,6 +1209,9 @@ ACCOUNT_FORMS = {
'reset_password_from_key': 'allauth.account.forms.ResetPasswordKeyForm',
'disconnect': 'allauth.socialaccount.forms.DisconnectForm',
}
ALLAUTH_2FA_FORMS = {'setup': 'InvenTree.forms.CustomTOTPDeviceForm'}
# Determine if multi-factor authentication is enabled for this server (default = True)
MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True)
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'
@@ -1272,7 +1276,7 @@ PLUGIN_TESTING_SETUP = get_setting(
) # Load plugins from setup hooks in testing?
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
PLUGIN_RETRY = get_setting(
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 5
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int
) # How often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?

@@ -3,6 +3,7 @@
import logging
from importlib import import_module
from django.conf import settings
from django.urls import NoReverseMatch, include, path, reverse
from allauth.account.models import EmailAddress
@@ -177,7 +178,9 @@ class SocialProviderListView(ListAPI):
data = {
'sso_enabled': InvenTree.sso.login_enabled(),
'sso_registration': InvenTree.sso.registration_enabled(),
'mfa_required': get_global_setting('LOGIN_ENFORCE_MFA'),
'mfa_required': settings.MFA_ENABLED
and get_global_setting('LOGIN_ENFORCE_MFA'),
'mfa_enabled': settings.MFA_ENABLED,
'providers': provider_list,
'registration_enabled': get_global_setting('LOGIN_ENABLE_REG'),
'password_forgotten_enabled': get_global_setting('LOGIN_ENABLE_PWD_FORGOT'),

@@ -1,7 +1,14 @@
"""Helper functions for Single Sign On functionality."""
import json
import logging
from django.contrib.auth.models import Group
from django.db.models.signals import post_save
from django.dispatch import receiver
from allauth.socialaccount.models import SocialAccount, SocialLogin
from common.settings import get_global_setting
from InvenTree.helpers import str2bool
@@ -75,3 +82,55 @@ def registration_enabled() -> bool:
def auto_registration_enabled() -> bool:
"""Return True if SSO auto-registration is enabled."""
return str2bool(get_global_setting('LOGIN_SIGNUP_SSO_AUTO'))
def ensure_sso_groups(sender, sociallogin: SocialLogin, **kwargs):
"""Sync groups from IdP each time a SSO user logs on.
This event listener is registered in the apps ready method.
"""
if not get_global_setting('LOGIN_ENABLE_SSO_GROUP_SYNC'):
return
group_key = get_global_setting('SSO_GROUP_KEY')
group_map = json.loads(get_global_setting('SSO_GROUP_MAP'))
# map SSO groups to InvenTree groups
group_names = []
for sso_group in sociallogin.account.extra_data.get(group_key, []):
if mapped_name := group_map.get(sso_group):
group_names.append(mapped_name)
# ensure user has groups
user = sociallogin.account.user
for group_name in group_names:
try:
user.groups.get(name=group_name)
except Group.DoesNotExist:
# user not in group yet
try:
group = Group.objects.get(name=group_name)
except Group.DoesNotExist:
logger.info(f'Creating group {group_name} as it did not exist')
group = Group(name=group_name)
group.save()
logger.info(f'Adding group {group_name} to user {user}')
user.groups.add(group)
# remove groups not listed by SSO if not disabled
if get_global_setting('SSO_REMOVE_GROUPS'):
for group in user.groups.all():
if not group.name in group_names:
logger.info(f'Removing group {group.name} from {user}')
user.groups.remove(group)
@receiver(post_save, sender=SocialAccount)
def on_social_account_created(sender, instance: SocialAccount, created: bool, **kwargs):
"""Sync SSO groups when new SocialAccount is added.
Since the allauth `social_account_added` signal is not sent for some reason, this
signal is simulated using post_save signals. The issue has been reported as
https://github.com/pennersr/django-allauth/issues/3834
"""
if created:
ensure_sso_groups(None, SocialLogin(account=instance))

@@ -1101,3 +1101,19 @@ a {
.large-treeview-icon {
font-size: 1em;
}
.api-icon {
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better font rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.test-statistics-table-total-row {
font-weight: bold;
border-top-style: double;
}

@@ -60,10 +60,6 @@ function exportFormatOptions() {
value: 'tsv',
display_name: 'TSV',
},
{
value: 'xls',
display_name: 'XLS',
},
{
value: 'xlsx',
display_name: 'XLSX',

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2024 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long

@@ -4,6 +4,6 @@ This file remains here for backwards compatibility,
as external plugins may import status codes from this file.
"""
from build.status_codes import *
from order.status_codes import *
from stock.status_codes import *
from build.status_codes import * # noqa: F403
from order.status_codes import * # noqa: F403
from stock.status_codes import * # noqa: F403

@@ -118,7 +118,7 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
if last_success:
threshold = datetime.now() - timedelta(days=n_days)
if last_success > threshold:
if last_success.date() > threshold.date():
logger.info(
"Last successful run for '%s' was too recent - skipping task", task_name
)
@@ -256,8 +256,8 @@ def offload_task(
_func(*args, **kwargs)
except Exception as exc:
log_error('InvenTree.offload_task')
raise_warning(f"WARNING: '{taskname}' not started due to {str(exc)}")
return False
raise_warning(f"WARNING: '{taskname}' failed due to {str(exc)}")
raise exc
# Finally, task either completed successfully or was offloaded
return True

@@ -438,9 +438,9 @@ def progress_bar(val, max_val, *args, **kwargs):
@register.simple_tag()
def get_color_theme_css(username):
def get_color_theme_css(user):
"""Return the custom theme .css file for the selected user."""
user_theme_name = get_user_color_theme(username)
user_theme_name = get_user_color_theme(user)
# Build path to CSS sheet
inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css')
@@ -451,12 +451,18 @@ def get_color_theme_css(username):
@register.simple_tag()
def get_user_color_theme(username):
def get_user_color_theme(user):
"""Get current user color theme."""
from common.models import ColorTheme
try:
user_theme = ColorTheme.objects.filter(user=username).get()
if not user.is_authenticated:
return 'default'
except Exception:
return 'default'
try:
user_theme = ColorTheme.objects.filter(user_obj=user).get()
user_theme_name = user_theme.name
if not user_theme_name or not ColorTheme.is_valid_choice(user_theme):
user_theme_name = 'default'

@@ -62,6 +62,7 @@ class APITests(InvenTreeAPITestCase):
"""Tests for the InvenTree API."""
fixtures = ['location', 'category', 'part', 'stock']
roles = ['part.view']
token = None
auto_login = False
@@ -132,6 +133,7 @@ class APITests(InvenTreeAPITestCase):
# Now log in!
self.basicAuth()
self.assignRole('part.view')
response = self.get(url)
@@ -147,12 +149,17 @@ class APITests(InvenTreeAPITestCase):
role_names = roles.keys()
# By default, 'view' permissions are provided
# By default, no permissions are provided
for rule in RuleSet.RULESET_NAMES:
self.assertIn(rule, role_names)
self.assertIn('view', roles[rule])
if roles[rule] is None:
continue
if rule == 'part':
self.assertIn('view', roles[rule])
else:
self.assertNotIn('view', roles[rule])
self.assertNotIn('add', roles[rule])
self.assertNotIn('change', roles[rule])
self.assertNotIn('delete', roles[rule])
@@ -297,6 +304,7 @@ class SearchTests(InvenTreeAPITestCase):
'order',
'sales_order',
]
roles = ['build.view', 'part.view']
def test_empty(self):
"""Test empty request."""
@@ -331,6 +339,19 @@ class SearchTests(InvenTreeAPITestCase):
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
expected_code=200,
)
self.assertEqual(
response.data['purchaseorder'],
{'error': 'User does not have permission to view this model'},
)
# Add permissions and try again
self.assignRole('purchase_order.view')
self.assignRole('sales_order.view')
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)

@@ -71,6 +71,7 @@ class MiddlewareTests(InvenTreeTestCase):
def test_error_exceptions(self):
"""Test that ignored errors are not logged."""
self.assignRole('part.view')
def check(excpected_nbr=0):
# Check that errors are empty

@@ -0,0 +1,121 @@
"""Test the sso module functionality."""
from django.contrib.auth.models import Group, User
from django.test import override_settings
from django.test.testcases import TransactionTestCase
from allauth.socialaccount.models import SocialAccount, SocialLogin
from common.models import InvenTreeSetting
from InvenTree import sso
from InvenTree.forms import RegistratonMixin
class Dummy:
"""Simulate super class of RegistratonMixin."""
def save_user(self, _request, user: User, *args) -> User:
"""This method is only used that the super() call of RegistrationMixin does not fail."""
return user
class MockRegistrationMixin(RegistratonMixin, Dummy):
"""Mocked implementation of the RegistrationMixin."""
class TestSsoGroupSync(TransactionTestCase):
"""Tests for the SSO group sync feature."""
def setUp(self):
"""Construct sociallogin object for test cases."""
# configure SSO
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO_GROUP_SYNC', True)
InvenTreeSetting.set_setting('SSO_GROUP_KEY', 'groups')
InvenTreeSetting.set_setting(
'SSO_GROUP_MAP', '{"idp_group": "inventree_group"}'
)
# configure sociallogin
extra_data = {'groups': ['idp_group']}
self.group = Group(name='inventree_group')
self.group.save()
# ensure default group exists
user = User(username='testuser', first_name='Test', last_name='User')
user.save()
account = SocialAccount(user=user, extra_data=extra_data)
self.sociallogin = SocialLogin(account=account)
def test_group_added_to_user(self):
"""Check that a new SSO group is added to the user."""
user: User = self.sociallogin.account.user
self.assertEqual(user.groups.count(), 0)
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
def test_group_already_exists(self):
"""Check that existing SSO group is not modified."""
user: User = self.sociallogin.account.user
user.groups.add(self.group)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
@override_settings(SSO_REMOVE_GROUPS=True)
def test_remove_non_sso_group(self):
"""Check that any group not provided by IDP is removed."""
user: User = self.sociallogin.account.user
# group must be saved to database first
group = Group(name='local_group')
group.save()
user.groups.add(group)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'local_group')
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
def test_override_default_group_with_sso_group(self):
"""The default group should be overridden if SSO groups are available."""
user: User = self.sociallogin.account.user
self.assertEqual(user.groups.count(), 0)
Group(id=42, name='default_group').save()
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
sso.ensure_sso_groups(None, self.sociallogin)
MockRegistrationMixin().save_user(None, user, None)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
def test_default_group_without_sso_group(self):
"""If no SSO group is specified, the default group should be applied."""
self.sociallogin.account.extra_data = {}
user: User = self.sociallogin.account.user
self.assertEqual(user.groups.count(), 0)
Group(id=42, name='default_group').save()
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
sso.ensure_sso_groups(None, self.sociallogin)
MockRegistrationMixin().save_user(None, user, None)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'default_group')
@override_settings(SSO_REMOVE_GROUPS=True)
def test_remove_groups_overrides_default_group(self):
"""If no SSO group is specified, the default group should not be added if SSO_REMOVE_GROUPS=True."""
user: User = self.sociallogin.account.user
self.sociallogin.account.extra_data = {}
self.assertEqual(user.groups.count(), 0)
Group(id=42, name='default_group').save()
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
sso.ensure_sso_groups(None, self.sociallogin)
MockRegistrationMixin().save_user(None, user, None)
# second ensure_sso_groups will be called by signal if social account changes
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(user.groups.count(), 0)
def test_sso_group_created_if_not_exists(self):
"""If the mapped group does not exist, a new group with the same name should be created."""
self.group.delete()
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 0)
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 1)

@@ -1,6 +1,5 @@
"""Test general functions and helpers."""
import json
import os
import time
from datetime import datetime, timedelta
@@ -789,33 +788,6 @@ class TestIncrement(TestCase):
self.assertEqual(result, b)
class TestMakeBarcode(TestCase):
"""Tests for barcode string creation."""
def test_barcode_extended(self):
"""Test creation of barcode with extended data."""
bc = helpers.MakeBarcode(
'part', 3, {'id': 3, 'url': 'www.google.com'}, brief=False
)
self.assertIn('part', bc)
self.assertIn('tool', bc)
self.assertIn('"tool": "InvenTree"', bc)
data = json.loads(bc)
self.assertEqual(data['part']['id'], 3)
self.assertEqual(data['part']['url'], 'www.google.com')
def test_barcode_brief(self):
"""Test creation of simple barcode."""
bc = helpers.MakeBarcode('stockitem', 7)
data = json.loads(bc)
self.assertEqual(len(data), 1)
self.assertEqual(data['stockitem'], 7)
class TestDownloadFile(TestCase):
"""Tests for DownloadFile."""

@@ -22,9 +22,6 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExport
import InvenTree.ready
from InvenTree.version import inventreeVersion
# Logger configuration
logger = logging.getLogger('inventree')
def setup_tracing(
endpoint: str,
@@ -46,6 +43,9 @@ def setup_tracing(
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
return
# Logger configuration
logger = logging.getLogger('inventree')
if resources_input is None:
resources_input = {}
if auth is None:

@@ -84,6 +84,9 @@ def getNewestMigrationFile(app, exclude_extension=True):
newest_num = num
newest_file = f
if not newest_file: # pragma: no cover
return newest_file
if exclude_extension:
newest_file = newest_file.replace('.py', '')
@@ -152,6 +155,17 @@ class UserMixin:
"""Lougout current user."""
self.client.logout()
@classmethod
def clearRoles(cls):
"""Remove all user roles from the registered user."""
for ruleset in cls.group.rule_sets.all():
ruleset.can_view = False
ruleset.can_change = False
ruleset.can_delete = False
ruleset.can_add = False
ruleset.save()
@classmethod
def assignRole(cls, role=None, assign_all: bool = False, group=None):
"""Set the user roles for the registered user.
@@ -190,7 +204,8 @@ class UserMixin:
ruleset.can_add = True
ruleset.save()
break
if not assign_all:
break
class PluginMixin:
@@ -229,7 +244,7 @@ class ExchangeRateMixin:
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
"""Testcase with user setup buildin."""
"""Testcase with user setup build in."""
pass
@@ -267,7 +282,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
) # pragma: no cover
if verbose:
if verbose or n >= value:
msg = '\r\n%s' % json.dumps(
context.captured_queries, indent=4
) # pragma: no cover
@@ -296,7 +311,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
if hasattr(response, 'content'):
print('content:', response.content)
self.assertEqual(expected_code, response.status_code)
self.assertEqual(response.status_code, expected_code)
def getActions(self, url):
"""Return a dict of the 'actions' available at a given endpoint.
@@ -314,17 +329,17 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
if data is None:
data = {}
expected_code = kwargs.pop('expected_code', None)
kwargs['format'] = kwargs.get('format', 'json')
max_queries = kwargs.get('max_query_count', self.MAX_QUERY_COUNT)
max_query_time = kwargs.get('max_query_time', self.MAX_QUERY_TIME)
expected_code = kwargs.pop('expected_code', None)
max_queries = kwargs.pop('max_query_count', self.MAX_QUERY_COUNT)
max_query_time = kwargs.pop('max_query_time', self.MAX_QUERY_TIME)
t1 = time.time()
with self.assertNumQueriesLessThan(max_queries, url=url):
response = method(url, data, **kwargs)
t2 = time.time()
dt = t2 - t1
@@ -401,12 +416,12 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
# Extract filename
disposition = response.headers['Content-Disposition']
result = re.search(r'attachment; filename="([\w.]+)"', disposition)
result = re.search(r'attachment; filename="([\w\d\-.]+)"', disposition)
fn = result.groups()[0]
if expected_fn is not None:
self.assertEqual(expected_fn, fn)
self.assertRegex(fn, expected_fn)
if decode:
# Decode data and return as StringIO file object

@@ -21,6 +21,7 @@ from sesame.views import LoginView
import build.api
import common.api
import company.api
import importer.api
import machine.api
import order.api
import part.api
@@ -34,6 +35,7 @@ from company.urls import company_urls, manufacturer_part_urls, supplier_part_url
from order.urls import order_urls
from part.urls import part_urls
from plugin.urls import get_plugin_urls
from stock.api import test_statistics_api_urls
from stock.urls import stock_urls
from web.urls import api_urls as web_api_urls
from web.urls import urlpatterns as platform_urls
@@ -80,11 +82,19 @@ admin.site.site_header = 'InvenTree Admin'
apipatterns = [
# Global search
path('admin/', include(common.api.admin_api_urls)),
path('bom/', include(part.api.bom_api_urls)),
path('build/', include(build.api.build_api_urls)),
path('company/', include(company.api.company_api_urls)),
path('importer/', include(importer.api.importer_api_urls)),
path('label/', include(report.api.label_api_urls)),
path('machine/', include(machine.api.machine_api_urls)),
path('order/', include(order.api.order_api_urls)),
path('part/', include(part.api.part_api_urls)),
path('report/', include(report.api.report_api_urls)),
path('search/', APISearchView.as_view(), name='api-search'),
path('settings/', include(common.api.settings_api_urls)),
path('part/', include(part.api.part_api_urls)),
path('bom/', include(part.api.bom_api_urls)),
path('company/', include(company.api.company_api_urls)),
path('stock/', include(stock.api.stock_api_urls)),
path(
'generate/',
include([
@@ -100,14 +110,8 @@ apipatterns = [
),
]),
),
path('stock/', include(stock.api.stock_api_urls)),
path('build/', include(build.api.build_api_urls)),
path('order/', include(order.api.order_api_urls)),
path('label/', include(report.api.label_api_urls)),
path('report/', include(report.api.report_api_urls)),
path('machine/', include(machine.api.machine_api_urls)),
path('test-statistics/', include(test_statistics_api_urls)),
path('user/', include(users.api.user_urls)),
path('admin/', include(common.api.admin_api_urls)),
path('web/', include(web_api_urls)),
# Plugin endpoints
path('', include(plugin.api.plugin_api_urls)),

@@ -13,6 +13,7 @@ from jinja2 import Template
from moneyed import CURRENCIES
import InvenTree.conversion
from common.settings import get_global_setting
def validate_physical_units(unit):
@@ -63,14 +64,10 @@ class AllowedURLValidator(validators.URLValidator):
def __call__(self, value):
"""Validate the URL."""
import common.models
self.schemes = allowable_url_schemes()
# Determine if 'strict' URL validation is required (i.e. if the URL must have a schema prefix)
strict_urls = common.models.InvenTreeSetting.get_setting(
'INVENTREE_STRICT_URLS', True, cache=False
)
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
if not strict_urls:
# Allow URLs which do not have a provided schema

@@ -3,6 +3,7 @@
Provides information on the current InvenTree version
"""
import logging
import os
import pathlib
import platform
@@ -14,20 +15,29 @@ from datetime import timedelta as td
import django
from django.conf import settings
from dulwich.repo import NotGitRepository, Repo
from common.settings import get_global_setting
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = '0.16.0 dev'
INVENTREE_SW_VERSION = '0.17.0 dev'
logger = logging.getLogger('inventree')
# Discover git
try:
from dulwich.repo import Repo
main_repo = Repo(pathlib.Path(__file__).parent.parent.parent.parent.parent)
main_commit = main_repo[main_repo.head()]
except (NotGitRepository, FileNotFoundError):
except (ImportError, ModuleNotFoundError):
logger.warning(
'Warning: Dulwich module not found, git information will not be available.'
)
main_repo = None
main_commit = None
except Exception:
main_repo = None
main_commit = None
@@ -53,13 +63,17 @@ def checkMinPythonVersion():
def inventreeInstanceName():
"""Returns the InstanceName settings for the current database."""
return get_global_setting('INVENTREE_INSTANCE', '')
from common.settings import get_global_setting
return get_global_setting('INVENTREE_INSTANCE')
def inventreeInstanceTitle():
"""Returns the InstanceTitle for the current database."""
if get_global_setting('INVENTREE_INSTANCE_TITLE', False):
return get_global_setting('INVENTREE_INSTANCE', 'InvenTree')
from common.settings import get_global_setting
if get_global_setting('INVENTREE_INSTANCE_TITLE'):
return get_global_setting('INVENTREE_INSTANCE')
return 'InvenTree'
@@ -103,7 +117,7 @@ def inventreeDocUrl():
def inventreeAppUrl():
"""Return URL for InvenTree app site."""
return f'https://docs.inventree.org/app/'
return 'https://docs.inventree.org/app/'
def inventreeCreditsUrl():
@@ -121,6 +135,8 @@ def isInvenTreeUpToDate():
A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION"
"""
from common.settings import get_global_setting
latest = get_global_setting(
'_INVENTREE_LATEST_VERSION', backup_value=None, create=False
)

@@ -614,7 +614,7 @@ class AppearanceSelectView(RedirectView):
"""Get current user color theme."""
try:
user_theme = common_models.ColorTheme.objects.filter(
user=self.request.user
user_obj=self.request.user
).get()
except common_models.ColorTheme.DoesNotExist:
user_theme = None
@@ -631,7 +631,7 @@ class AppearanceSelectView(RedirectView):
# Create theme entry if user did not select one yet
if not user_theme:
user_theme = common_models.ColorTheme()
user_theme.user = request.user
user_theme.user_obj = request.user
if theme:
try:

@@ -8,19 +8,20 @@ from django.contrib.auth.models import User
from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
from importer.mixins import DataExportViewMixin
from InvenTree.api import BulkDeleteMixin, MetadataView
from generic.states.api import StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.helpers import str2bool, isNull
from build.status_codes import BuildStatus, BuildStatusGroups
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import common.models
import build.admin
import build.serializers
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
from build.models import Build, BuildLine, BuildItem
import part.models
from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
@@ -125,7 +126,7 @@ class BuildMixin:
return queryset
class BuildList(APIDownloadMixin, BuildMixin, ListCreateAPI):
class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
"""API endpoint for accessing a list of Build objects.
- GET: Return list of objects (with filters)
@@ -176,15 +177,6 @@ class BuildList(APIDownloadMixin, BuildMixin, ListCreateAPI):
return queryset
def download_queryset(self, queryset, export_format):
"""Download the queryset data as a file."""
dataset = build.admin.BuildResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_BuildOrders.{export_format}"
return DownloadFile(filedata, filename)
def filter_queryset(self, queryset):
"""Custom query filtering for the BuildList endpoint."""
queryset = super().filter_queryset(queryset)
@@ -351,7 +343,7 @@ class BuildLineEndpoint:
return queryset
class BuildLineList(BuildLineEndpoint, ListCreateAPI):
class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
"""API endpoint for accessing a list of BuildLine objects"""
filterset_class = BuildLineFilter
@@ -367,6 +359,8 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
'unit_quantity',
'available_stock',
'trackable',
'allow_variants',
'inherited',
]
ordering_field_aliases = {
@@ -376,6 +370,8 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
'consumable': 'bom_item__consumable',
'optional': 'bom_item__optional',
'trackable': 'bom_item__sub_part__trackable',
'allow_variants': 'bom_item__allow_variants',
'inherited': 'bom_item__inherited',
}
search_fields = [
@@ -474,9 +470,19 @@ class BuildFinish(BuildOrderContextMixin, CreateAPI):
"""API endpoint for marking a build as finished (completed)."""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildCompleteSerializer
def get_queryset(self):
"""Return the queryset for the BuildFinish API endpoint."""
queryset = super().get_queryset()
queryset = queryset.prefetch_related(
'build_lines',
'build_lines__allocations'
)
return queryset
class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
"""API endpoint for 'automatically' allocating stock against a build order.
@@ -488,7 +494,6 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
"""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildAutoAllocationSerializer
@@ -504,10 +509,22 @@ class BuildAllocate(BuildOrderContextMixin, CreateAPI):
"""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildAllocationSerializer
class BuildIssue(BuildOrderContextMixin, CreateAPI):
"""API endpoint for issuing a BuildOrder."""
queryset = Build.objects.all()
serializer_class = build.serializers.BuildIssueSerializer
class BuildHold(BuildOrderContextMixin, CreateAPI):
"""API endpoint for placing a BuildOrder on hold."""
queryset = Build.objects.all()
serializer_class = build.serializers.BuildHoldSerializer
class BuildCancel(BuildOrderContextMixin, CreateAPI):
"""API endpoint for cancelling a BuildOrder."""
@@ -553,15 +570,17 @@ class BuildItemFilter(rest_filters.FilterSet):
return queryset.filter(install_into=None)
class BuildItemList(ListCreateAPI):
class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
"""API endpoint for accessing a list of BuildItem objects.
- GET: Return list of objects
- POST: Create a new BuildItem object
"""
queryset = BuildItem.objects.all()
serializer_class = build.serializers.BuildItemSerializer
filterset_class = BuildItemFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
def get_serializer(self, *args, **kwargs):
"""Returns a BuildItemSerializer instance based on the request."""
@@ -577,16 +596,25 @@ class BuildItemList(ListCreateAPI):
return self.serializer_class(*args, **kwargs)
def get_queryset(self):
"""Override the queryset method, to allow filtering by stock_item.part."""
queryset = BuildItem.objects.all()
"""Override the queryset method, to perform custom prefetch."""
queryset = super().get_queryset()
queryset = queryset.select_related(
'build_line',
'build_line__build',
'build_line__bom_item',
'build_line__bom_item__part',
'build_line__bom_item__sub_part',
'install_into',
'stock_item',
'stock_item__location',
'stock_item__part',
'stock_item__supplier_part__part',
'stock_item__supplier_part__supplier',
'stock_item__supplier_part__manufacturer_part',
'stock_item__supplier_part__manufacturer_part__manufacturer',
).prefetch_related(
'stock_item__location__tags',
)
return queryset
@@ -609,37 +637,30 @@ class BuildItemList(ListCreateAPI):
return queryset
filter_backends = [
DjangoFilterBackend,
ordering_fields = [
'part',
'sku',
'quantity',
'location',
'reference',
]
ordering_field_aliases = {
'part': 'stock_item__part__name',
'sku': 'stock_item__supplier_part__SKU',
'location': 'stock_item__location__name',
'reference': 'build_line__bom_item__reference',
}
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
filterset_fields = [
'build',
search_fields = [
'stock_item__supplier_part__SKU',
'stock_item__part__name',
'build_line__bom_item__reference',
]
class BuildAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a BuildOrderAttachment object."""
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
build_api_urls = [
# Attachments
path('attachment/', include([
path('<int:pk>/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
path('', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])),
# Build lines
path('line/', include([
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
@@ -663,6 +684,8 @@ build_api_urls = [
path('create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
path('delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
path('scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'),
path('issue/', BuildIssue.as_view(), name='api-build-issue'),
path('hold/', BuildHold.as_view(), name='api-build-hold'),
path('finish/', BuildFinish.as_view(), name='api-build-finish'),
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),

@@ -5,6 +5,8 @@ from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
from build.status_codes import BuildStatus
class Migration(migrations.Migration):
@@ -40,7 +42,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='build',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Production'), (30, 'Cancelled'), (40, 'Complete')], default=10, help_text='Build status code', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Build Status'),
field=models.PositiveIntegerField(choices=BuildStatus.items(), default=BuildStatus.PENDING.value, help_text='Build status code', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Build Status'),
),
migrations.AlterField(
model_name='build',

@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
name='BuildOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100)),
('upload_date', models.DateField(auto_now_add=True, null=True)),
('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='build.Build')),

@@ -65,7 +65,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='buildorderattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
),
migrations.AlterField(
model_name='buildorderattachment',

@@ -20,6 +20,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='buildorderattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
),
]

@@ -0,0 +1,21 @@
# Generated by Django 4.2.12 on 2024-06-09 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0026_auto_20240608_1238'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.DeleteModel(
name='BuildOrderAttachment',
),
]

@@ -2,7 +2,6 @@
import decimal
import logging
import os
from datetime import datetime
from django.conf import settings
@@ -26,6 +25,7 @@ from build.status_codes import BuildStatus, BuildStatusGroups
from stock.status_codes import StockStatus, StockHistoryCode
from build.validators import generate_next_build_reference, validate_build_order_reference
from generic.states import StateTransitionMixin
import InvenTree.fields
import InvenTree.helpers
@@ -50,11 +50,13 @@ logger = logging.getLogger('inventree')
class Build(
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
InvenTree.models.ReferenceIndexingMixin,
StateTransitionMixin,
MPTTModel):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
@@ -103,7 +105,7 @@ class Build(
}
@classmethod
def api_defaults(cls, request):
def api_defaults(cls, request=None):
"""Return default values for this model when issuing an API OPTIONS request."""
defaults = {
'reference': generate_next_build_reference(),
@@ -114,13 +116,42 @@ class Build(
return defaults
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return "BO"
def save(self, *args, **kwargs):
"""Custom save method for the BuildOrder model"""
self.validate_reference_field(self.reference)
self.reference_int = self.rebuild_reference_field(self.reference)
# Check part when initially creating the build order
if not self.pk or self.has_field_changed('part'):
if get_global_setting('BUILDORDER_REQUIRE_VALID_BOM'):
# Check that the BOM is valid
if not self.part.is_bom_valid():
raise ValidationError({
'part': _('Assembly BOM has not been validated')
})
if get_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART'):
# Check that the part is active
if not self.part.active:
raise ValidationError({
'part': _('Build order cannot be created for an inactive part')
})
if get_global_setting('BUILDORDER_REQUIRE_LOCKED_PART'):
# Check that the part is locked
if not self.part.locked:
raise ValidationError({
'part': _('Build order cannot be created for an unlocked part')
})
# On first save (i.e. creation), run some extra checks
if self.pk is None:
# Set the destination location (if not specified)
if not self.destination:
self.destination = self.part.get_default_location()
@@ -364,9 +395,9 @@ class Build(
def sub_builds(self, cascade=True):
"""Return all Build Order objects under this one."""
if cascade:
return Build.objects.filter(parent=self.pk)
descendants = self.get_descendants(include_self=True)
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
return self.get_descendants(include_self=False)
else:
return self.get_children()
def sub_build_count(self, cascade=True):
"""Return the number of sub builds under this one.
@@ -376,6 +407,11 @@ class Build(
"""
return self.sub_builds(cascade=cascade).count()
@property
def has_open_child_builds(self):
"""Return True if this build order has any open child builds."""
return self.sub_builds().filter(status__in=BuildStatusGroups.ACTIVE_CODES).exists()
@property
def is_overdue(self):
"""Returns true if this build is "overdue".
@@ -544,6 +580,13 @@ class Build(
- Completed count must meet the required quantity
- Untracked parts must be allocated
"""
if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and self.has_open_child_builds:
return False
if self.status != BuildStatus.PRODUCTION.value:
return False
if self.incomplete_count > 0:
return False
@@ -572,8 +615,22 @@ class Build(
def complete_build(self, user, trim_allocated_stock=False):
"""Mark this build as complete."""
return self.handle_transition(
self.status, BuildStatus.COMPLETE.value, self, self._action_complete, user=user, trim_allocated_stock=trim_allocated_stock
)
def _action_complete(self, *args, **kwargs):
"""Action to be taken when a build is completed."""
import build.tasks
trim_allocated_stock = kwargs.pop('trim_allocated_stock', False)
user = kwargs.pop('user', None)
# Prevent completion if there are open child builds
if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and self.has_open_child_builds:
return
if self.incomplete_count > 0:
return
@@ -635,6 +692,59 @@ class Build(
target_exclude=[user],
)
@transaction.atomic
def issue_build(self):
"""Mark the Build as IN PRODUCTION.
Args:
user: The user who is issuing the build
"""
return self.handle_transition(
self.status, BuildStatus.PENDING.value, self, self._action_issue
)
@property
def can_issue(self):
"""Returns True if this BuildOrder can be issued."""
return self.status in [
BuildStatus.PENDING.value,
BuildStatus.ON_HOLD.value,
]
def _action_issue(self, *args, **kwargs):
"""Perform the action to mark this order as PRODUCTION."""
if self.can_issue:
self.status = BuildStatus.PRODUCTION.value
self.save()
trigger_event('build.issued', id=self.pk)
@transaction.atomic
def hold_build(self):
"""Mark the Build as ON HOLD."""
return self.handle_transition(
self.status, BuildStatus.ON_HOLD.value, self, self._action_hold
)
@property
def can_hold(self):
"""Returns True if this BuildOrder can be placed on hold"""
return self.status in [
BuildStatus.PENDING.value,
BuildStatus.PRODUCTION.value,
]
def _action_hold(self, *args, **kwargs):
"""Action to be taken when a build is placed on hold."""
if self.can_hold:
self.status = BuildStatus.ON_HOLD.value
self.save()
trigger_event('build.hold', id=self.pk)
@transaction.atomic
def cancel_build(self, user, **kwargs):
"""Mark the Build as CANCELLED.
@@ -644,8 +754,17 @@ class Build(
- Save the Build object
"""
return self.handle_transition(
self.status, BuildStatus.CANCELLED.value, self, self._action_cancel, user=user, **kwargs
)
def _action_cancel(self, *args, **kwargs):
"""Action to be taken when a build is cancelled."""
import build.tasks
user = kwargs.pop('user', None)
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
@@ -867,7 +986,10 @@ class Build(
items_to_save = []
items_to_delete = []
for build_line in self.untracked_line_items:
lines = self.untracked_line_items
lines = lines.prefetch_related('allocations')
for build_line in lines:
reduce_by = build_line.allocated_quantity() - build_line.quantity
@@ -1246,7 +1368,7 @@ class Build(
@property
def is_complete(self):
"""Returns True if the build status is COMPLETE."""
return self.status == BuildStatus.COMPLETE
return self.status == BuildStatus.COMPLETE.value
@transaction.atomic
def create_build_line_items(self, prevent_duplicates=True):
@@ -1322,16 +1444,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
instance.update_build_line_items()
class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a BuildOrder object."""
def getSubdir(self):
"""Return the media file subdirectory for storing BuildOrder attachments"""
return os.path.join('bo_files', str(self.build.id))
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build.

@@ -1,5 +1,7 @@
"""JSON serializers for Build API."""
from decimal import Decimal
from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _
@@ -13,8 +15,7 @@ from django.db.models.functions import Coalesce
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializer
from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer
import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
@@ -22,18 +23,22 @@ from stock.status_codes import StockStatus
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer
from stock.serializers import StockItemSerializerBrief, LocationBriefSerializer
import common.models
from common.serializers import ProjectCodeSerializer
from common.settings import get_global_setting
from importer.mixins import DataImportExportSerializerMixin
import company.serializers
import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
import part.serializers as part_serializers
from users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
from .models import Build, BuildLine, BuildItem
from .status_codes import BuildStatus
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializes a Build object."""
class Meta:
@@ -51,8 +56,10 @@ class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
'destination',
'parent',
'part',
'part_name',
'part_detail',
'project_code',
'project_code_label',
'project_code_detail',
'overdue',
'reference',
@@ -83,7 +90,9 @@ class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
status_text = serializers.CharField(source='get_status_display', read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
part_detail = part_serializers.PartBriefSerializer(source='part', many=False, read_only=True)
part_name = serializers.CharField(source='part.name', read_only=True, label=_('Part Name'))
quantity = InvenTreeDecimalField()
@@ -95,6 +104,8 @@ class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
barcode_hash = serializers.CharField(read_only=True)
project_code_label = serializers.CharField(source='project_code.code', read_only=True, label=_('Project Code Label'))
project_code_detail = ProjectCodeSerializer(source='project_code', many=False, read_only=True)
@staticmethod
@@ -125,7 +136,7 @@ class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
self.fields.pop('part_detail', None)
reference = serializers.CharField(required=True)
@@ -202,7 +213,7 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer):
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
min_value=Decimal(0),
required=True,
label=_('Quantity'),
help_text=_('Enter quantity for build output'),
@@ -249,7 +260,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
min_value=Decimal(0),
required=True,
label=_('Quantity'),
help_text=_('Enter quantity for build output'),
@@ -588,6 +599,33 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
)
class BuildIssueSerializer(serializers.Serializer):
"""DRF serializer for issuing a build order."""
class Meta:
"""Serializer metaclass"""
fields = []
def save(self):
"""Issue the specified build order"""
build = self.context['build']
build.issue_build()
class BuildHoldSerializer(serializers.Serializer):
"""DRF serializer for placing a BuildOrder on hold."""
class Meta:
"""Serializer metaclass."""
fields = []
def save(self):
"""Place the specified build on hold."""
build = self.context['build']
build.hold_build()
class BuildCancelSerializer(serializers.Serializer):
"""DRF serializer class for cancelling an active BuildOrder"""
@@ -728,6 +766,12 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Perform validation of this serializer prior to saving"""
build = self.context['build']
if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and build.has_open_child_builds:
raise ValidationError(_("Build order has open child build orders"))
if build.status != BuildStatus.PRODUCTION.value:
raise ValidationError(_("Build order must be in production state"))
if build.incomplete_count > 0:
raise ValidationError(_("Build order has incomplete outputs"))
@@ -857,7 +901,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
min_value=Decimal(0),
required=True
)
@@ -1050,8 +1094,26 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
raise ValidationError(_("Failed to start auto-allocation task"))
class BuildItemSerializer(InvenTreeModelSerializer):
"""Serializes a BuildItem object."""
class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializes a BuildItem object, which is an allocation of a stock item against a build order."""
# These fields are only used for data export
export_only_fields = [
'bom_part_id',
'bom_part_name',
'build_reference',
'sku',
'mpn',
'location_name',
'part_id',
'part_name',
'part_ipn',
'part_description',
'available_quantity',
'item_batch_code',
'item_serial',
'item_packaging',
]
class Meta:
"""Serializer metaclass"""
@@ -1063,23 +1125,33 @@ class BuildItemSerializer(InvenTreeModelSerializer):
'install_into',
'stock_item',
'quantity',
'location',
# Detail fields, can be included or excluded
'build_detail',
'location_detail',
'part_detail',
'stock_item_detail',
'build_detail',
'supplier_part_detail',
# The following fields are only used for data export
'bom_reference',
'bom_part_id',
'bom_part_name',
'build_reference',
'location_name',
'mpn',
'sku',
'part_id',
'part_name',
'part_ipn',
'part_description',
'available_quantity',
'item_batch_code',
'item_serial_number',
'item_packaging',
]
# Annotated fields
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
# Extra (optional) detail fields
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
quantity = InvenTreeDecimalField()
def __init__(self, *args, **kwargs):
"""Determine which extra details fields should be included"""
part_detail = kwargs.pop('part_detail', True)
@@ -1090,21 +1162,65 @@ class BuildItemSerializer(InvenTreeModelSerializer):
super().__init__(*args, **kwargs)
if not part_detail:
self.fields.pop('part_detail')
self.fields.pop('part_detail', None)
if not location_detail:
self.fields.pop('location_detail')
self.fields.pop('location_detail', None)
if not stock_detail:
self.fields.pop('stock_item_detail')
self.fields.pop('stock_item_detail', None)
if not build_detail:
self.fields.pop('build_detail')
self.fields.pop('build_detail', None)
# Export-only fields
sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True)
mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True)
location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True)
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True)
item_packaging = serializers.CharField(source='stock_item.packaging', label=_('Packaging'), read_only=True)
# Part detail fields
part_id = serializers.PrimaryKeyRelatedField(source='stock_item.part', label=_('Part ID'), many=False, read_only=True)
part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True)
part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True)
part_description = serializers.CharField(source='stock_item.part.description', label=_('Part Description'), read_only=True)
# BOM Item Part ID (it may be different to the allocated part)
bom_part_id = serializers.PrimaryKeyRelatedField(source='build_line.bom_item.sub_part', label=_('BOM Part ID'), many=False, read_only=True)
bom_part_name = serializers.CharField(source='build_line.bom_item.sub_part.name', label=_('BOM Part Name'), read_only=True)
item_batch_code = serializers.CharField(source='stock_item.batch', label=_('Batch Code'), read_only=True)
item_serial_number = serializers.CharField(source='stock_item.serial', label=_('Serial Number'), read_only=True)
# Annotated fields
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
# Extra (optional) detail fields
part_detail = part_serializers.PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, read_only=True)
location_detail = LocationBriefSerializer(source='stock_item.location', read_only=True)
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
supplier_part_detail = company.serializers.SupplierPartSerializer(source='stock_item.supplier_part', many=False, read_only=True, brief=True)
quantity = InvenTreeDecimalField(label=_('Allocated Quantity'))
available_quantity = InvenTreeDecimalField(source='stock_item.quantity', read_only=True, label=_('Available Quantity'))
class BuildLineSerializer(InvenTreeModelSerializer):
class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for a BuildItem object."""
export_exclude_fields = [
'allocations',
]
export_only_fields = [
'part_description',
'part_category_name',
]
class Meta:
"""Serializer metaclass"""
@@ -1118,6 +1234,20 @@ class BuildLineSerializer(InvenTreeModelSerializer):
'quantity',
'allocations',
# BOM item detail fields
'reference',
'consumable',
'optional',
'trackable',
'inherited',
'allow_variants',
# Part detail fields
'part',
'part_name',
'part_IPN',
'part_category_id',
# Annotated fields
'allocated',
'in_production',
@@ -1127,6 +1257,10 @@ class BuildLineSerializer(InvenTreeModelSerializer):
'available_variant_stock',
'total_available_stock',
'external_stock',
# Extra fields only for data export
'part_description',
'part_category_name',
]
read_only_fields = [
@@ -1135,13 +1269,30 @@ class BuildLineSerializer(InvenTreeModelSerializer):
'allocations',
]
quantity = serializers.FloatField()
# Part info fields
part = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part', label=_('Part'), many=False, read_only=True)
part_name = serializers.CharField(source='bom_item.sub_part.name', label=_('Part Name'), read_only=True)
part_IPN = serializers.CharField(source='bom_item.sub_part.IPN', label=_('Part IPN'), read_only=True)
part_description = serializers.CharField(source='bom_item.sub_part.description', label=_('Part Description'), read_only=True)
part_category_id = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part.category', label=_('Part Category ID'), read_only=True)
part_category_name = serializers.CharField(source='bom_item.sub_part.category.name', label=_('Part Category Name'), read_only=True)
# BOM item info fields
reference = serializers.CharField(source='bom_item.reference', label=_('Reference'), read_only=True)
consumable = serializers.BooleanField(source='bom_item.consumable', label=_('Consumable'), read_only=True)
optional = serializers.BooleanField(source='bom_item.optional', label=_('Optional'), read_only=True)
trackable = serializers.BooleanField(source='bom_item.sub_part.trackable', label=_('Trackable'), read_only=True)
inherited = serializers.BooleanField(source='bom_item.inherited', label=_('Inherited'), read_only=True)
allow_variants = serializers.BooleanField(source='bom_item.allow_variants', label=_('Allow Variants'), read_only=True)
quantity = serializers.FloatField(label=_('Quantity'))
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
# Foreign key fields
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
bom_item_detail = part_serializers.BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
allocations = BuildItemSerializer(many=True, read_only=True)
# Annotated (calculated) fields
@@ -1165,10 +1316,10 @@ class BuildLineSerializer(InvenTreeModelSerializer):
read_only=True
)
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
total_available_stock = serializers.FloatField(read_only=True)
external_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True, label=_('Available Substitute Stock'))
available_variant_stock = serializers.FloatField(read_only=True, label=_('Available Variant Stock'))
total_available_stock = serializers.FloatField(read_only=True, label=_('Total Available Stock'))
external_stock = serializers.FloatField(read_only=True, label=_('External Stock'))
@staticmethod
def annotate_queryset(queryset, build=None):
@@ -1187,16 +1338,20 @@ class BuildLineSerializer(InvenTreeModelSerializer):
"""
queryset = queryset.select_related(
'build', 'bom_item',
'build',
'bom_item',
'bom_item__part',
'bom_item__part__pricing_data',
'bom_item__sub_part',
'bom_item__sub_part__pricing_data',
)
# Pre-fetch related fields
queryset = queryset.prefetch_related(
'bom_item__sub_part',
'bom_item__sub_part__tags',
'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations',
'bom_item__sub_part__tags',
'bom_item__substitutes',
'bom_item__substitutes__part__stock_items',
@@ -1208,6 +1363,11 @@ class BuildLineSerializer(InvenTreeModelSerializer):
'allocations__stock_item__part',
'allocations__stock_item__location',
'allocations__stock_item__location__tags',
'allocations__stock_item__supplier_part',
'allocations__stock_item__supplier_part__part',
'allocations__stock_item__supplier_part__supplier',
'allocations__stock_item__supplier_part__manufacturer_part',
'allocations__stock_item__supplier_part__manufacturer_part__manufacturer',
)
# Annotate the "allocated" quantity
@@ -1311,15 +1471,3 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
return queryset
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for a BuildAttachment."""
class Meta:
"""Serializer metaclass"""
model = BuildOrderAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields([
'build',
])

@@ -9,7 +9,8 @@ class BuildStatus(StatusCode):
"""Build status codes."""
PENDING = 10, _('Pending'), 'secondary' # Build is pending / active
PRODUCTION = 20, _('Production'), 'primary' # BuildOrder is in production
PRODUCTION = 20, _('Production'), 'primary' # Build is in production
ON_HOLD = 25, _('On Hold'), 'warning' # Build is on hold
CANCELLED = 30, _('Cancelled'), 'danger' # Build was cancelled
COMPLETE = 40, _('Complete'), 'success' # Build is complete
@@ -17,4 +18,8 @@ class BuildStatus(StatusCode):
class BuildStatusGroups:
"""Groups for BuildStatus codes."""
ACTIVE_CODES = [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value]
ACTIVE_CODES = [
BuildStatus.PENDING.value,
BuildStatus.ON_HOLD.value,
BuildStatus.PRODUCTION.value,
]

@@ -69,22 +69,30 @@ src="{% static 'img/blank_image.png' %}"
</button>
<ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li>
{% if build.is_active %}
<li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %}
{% if roles.build.add %}
<li><a class='dropdown-item' href='#' id='build-duplicate'><span class='fas fa-clone'></span> {% trans "Duplicate Build" %}</a></li>
{% endif %}
{% if build.can_hold %}
<li><a class='dropdown-item' href='#' id='build-hold'><span class='fas fa-hand-paper icon-yellow'></span> {% trans "Hold Build" %}</a></li>
{% endif %}
{% if build.is_active %}
<li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %}
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
<li><a class='dropdown-item' href='#' id='build-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Build" %}</a>
{% endif %}
</ul>
</div>
{% if build.active %}
{% if build.can_issue %}
<button id='build-issue' title='{% trans "Isueue Build" %}' class='btn btn-primary'>
<span class='fas fa-paper-plane'></span> {% trans "Issue Build" %}
</button>
{% elif build.active %}
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
<span class='fas fa-check-circle'></span> {% trans "Complete Build" %}
</button>
{% endif %}
{% endif %}
{% endblock actions %}
@@ -244,6 +252,31 @@ src="{% static 'img/blank_image.png' %}"
);
});
$('#build-hold').click(function() {
holdOrder(
'{% url "api-build-hold" build.pk %}',
{
reload: true,
}
);
});
$('#build-issue').click(function() {
constructForm('{% url "api-build-issue" build.pk %}', {
method: 'POST',
title: '{% trans "Issue Build Order" %}',
confirm: true,
preFormContent: `
<div class='alert alert-block alert-info'>
{% trans "Issue this Build Order?" %}
</div>
`,
onSuccess: function(response) {
window.location.reload();
}
});
});
$("#build-complete").on('click', function() {
completeBuildOrder({{ build.pk }});
});
@@ -277,7 +310,7 @@ src="{% static 'img/blank_image.png' %}"
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Build Order QR Code" escape %}',
'{"build": {{ build.pk }} }'
'{{ build.barcode }}'
);
});
@@ -298,6 +331,12 @@ src="{% static 'img/blank_image.png' %}"
build: {{ build.pk }},
});
});
{% if build.part.trackable > 0 %}
onPanelLoad("test-statistics", function() {
prepareTestStatisticsTable('build', '{% url "api-test-statistics-by-build" build.pk %}')
});
{% endif %}
{% endif %}
{% endif %}

@@ -174,7 +174,7 @@
<div class='panel panel-hidden' id='panel-allocate'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Allocate Stock to Build" %}</h4>
<h4>{% trans "Build Order Line Items" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.build.add and build.active %}
@@ -231,6 +231,18 @@
</div>
</div>
<div class='panel panel-hidden' id='panel-allocated'>
<div class='panel-heading'>
<h4>{% trans "Allocated Stock" %}</h4>
</div>
<div class='panel-content'>
<div id='build-allocated-stock-toolbar'>
{% include "filter_list.html" with id='buildorderallocatedstock' %}
</div>
<table class='table table-striped table-condensed' id='allocated-stock-table' data-toolbar='#build-allocated-stock-toolbar'></table>
</div>
</div>
<div class='panel panel-hidden' id='panel-consumed'>
<div class='panel-heading'>
<h4>
@@ -255,6 +267,21 @@
</div>
</div>
<div class='panel panel-hidden' id='panel-test-statistics'>
<div class='panel-heading'>
<h4>
{% trans "Build test statistics" %}
</h4>
</div>
<div class='panel-content'>
<div id='teststatistics-button-toolbar'>
{% include "filter_list.html" with id="buildteststatistics" %}
</div>
{% include "test_statistics_table.html" with prefix="build-" %}
</div>
</div>
<div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@@ -290,6 +317,10 @@
{% block js_ready %}
{{ block.super }}
onPanelLoad('allocated', function() {
loadBuildOrderAllocatedStockTable($('#allocated-stock-table'), {{ build.pk }});
});
onPanelLoad('consumed', function() {
loadStockTable($('#consumed-stock-table'), {
filterTarget: '#filter-list-consumed-stock',
@@ -326,18 +357,7 @@ onPanelLoad('children', function() {
});
onPanelLoad('attachments', function() {
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
filters: {
build: {{ build.pk }},
},
fields: {
build: {
value: {{ build.pk }},
hidden: true,
}
}
});
loadAttachmentTable('build', {{ build.pk }});
});
onPanelLoad('notes', function() {

@@ -5,17 +5,25 @@
{% trans "Build Order Details" as text %}
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
{% if build.is_active %}
{% trans "Allocate Stock" as text %}
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
{% trans "Line Items" as text %}
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-list-ol" %}
{% trans "Incomplete Outputs" as text %}
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
{% endif %}
{% trans "Completed Outputs" as text %}
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
{% if build.is_active %}
{% trans "Allocated Stock" as text %}
{% include "sidebar_item.html" with label='allocated' text=text icon="fa-list" %}
{% endif %}
{% trans "Consumed Stock" as text %}
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-tasks" %}
{% trans "Child Build Orders" as text %}
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
{% if build.part.trackable %}
{% trans "Test Statistics" as text %}
{% include "sidebar_item.html" with label='test-statistics' text=text icon="fa-chart-line" %}
{% endif %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}

@@ -564,16 +564,16 @@ class BuildTest(BuildAPITest):
def test_download_build_orders(self):
"""Test that we can download a list of build orders via the API"""
required_cols = [
'reference',
'status',
'completed',
'batch',
'notes',
'title',
'part',
'part_name',
'id',
'quantity',
'Reference',
'Build Status',
'Completed items',
'Batch Code',
'Notes',
'Description',
'Part',
'Part Name',
'ID',
'Quantity',
]
excluded_cols = [
@@ -597,13 +597,13 @@ class BuildTest(BuildAPITest):
for row in data:
build = Build.objects.get(pk=row['id'])
build = Build.objects.get(pk=row['ID'])
self.assertEqual(str(build.part.pk), row['part'])
self.assertEqual(build.part.full_name, row['part_name'])
self.assertEqual(str(build.part.pk), row['Part'])
self.assertEqual(build.part.name, row['Part Name'])
self.assertEqual(build.reference, row['reference'])
self.assertEqual(build.title, row['title'])
self.assertEqual(build.reference, row['Reference'])
self.assertEqual(build.title, row['Description'])
class BuildAllocationTest(BuildAPITest):
@@ -1015,7 +1015,7 @@ class BuildOverallocationTest(BuildAPITest):
'accept_overallocated': 'trim',
},
expected_code=201,
max_query_count=550, # TODO: Come back and refactor this
max_query_count=600, # TODO: Come back and refactor this
)
self.build.refresh_from_db()

@@ -15,6 +15,7 @@ import common.models
from common.settings import set_global_setting
import build.tasks
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from build.status_codes import BuildStatus
from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
from stock.models import StockItem, StockItemTestResult
from users.models import Owner
@@ -175,6 +176,7 @@ class BuildTestBase(TestCase):
part=cls.assembly,
quantity=10,
issued_by=get_user_model().objects.get(pk=1),
status=BuildStatus.PENDING,
)
# Create some BuildLine items we can use later on
@@ -321,6 +323,10 @@ class BuildTest(BuildTestBase):
# Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
self.assertTrue(self.build.is_active)
self.assertTrue(self.build.can_hold)
self.assertTrue(self.build.can_issue)
# Build has two build outputs
self.assertEqual(self.build.output_count, 2)
@@ -470,6 +476,11 @@ class BuildTest(BuildTestBase):
def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function"""
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
self.build.issue_build()
self.assertEqual(self.build.status, status.BuildStatus.PRODUCTION)
# Fully allocate tracked stock (not eligible for trimming)
self.allocate_stock(
self.output_1,
@@ -516,6 +527,7 @@ class BuildTest(BuildTestBase):
self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None)
self.assertTrue(self.build.can_complete)
n = StockItem.objects.filter(consumed_by=self.build).count()
@@ -583,6 +595,8 @@ class BuildTest(BuildTestBase):
self.stock_2_1.quantity = 30
self.stock_2_1.save()
self.build.issue_build()
# Allocate non-tracked parts
self.allocate_stock(
None,

@@ -19,7 +19,6 @@ class TestForwardMigrations(MigratorTestCase):
name='Widget',
description='Buildable Part',
active=True,
level=0, lft=0, rght=0, tree_id=0,
)
Build = self.old_state.apps.get_model('build', 'build')
@@ -61,7 +60,6 @@ class TestReferenceMigration(MigratorTestCase):
part = Part.objects.create(
name='Part',
description='A test part',
level=0, lft=0, rght=0, tree_id=0,
)
Build = self.old_state.apps.get_model('build', 'build')

@@ -1,6 +1,7 @@
"""Basic unit tests for the BuildOrder app"""
from django.conf import settings
from django.core.exceptions import ValidationError
from django.test import tag
from django.urls import reverse
@@ -9,8 +10,10 @@ from datetime import datetime, timedelta
from InvenTree.unit_test import InvenTreeTestCase
from .models import Build
from part.models import Part, BomItem
from stock.models import StockItem
from common.settings import get_global_setting, set_global_setting
from build.status_codes import BuildStatus
@@ -88,6 +91,79 @@ class BuildTestSimple(InvenTreeTestCase):
self.assertEqual(build.status, BuildStatus.CANCELLED)
def test_build_create(self):
"""Test creation of build orders via API."""
n = Build.objects.count()
# Find an assembly part
assembly = Part.objects.filter(assembly=True).first()
assembly.active = True
assembly.locked = False
assembly.save()
self.assertEqual(assembly.get_bom_items().count(), 0)
# Let's create some BOM items for this assembly
for component in Part.objects.filter(assembly=False, component=True)[:15]:
try:
BomItem.objects.create(
part=assembly,
sub_part=component,
reference='xxx',
quantity=5
)
except ValidationError:
pass
# The assembly has a BOM, and is now *invalid*
self.assertGreater(assembly.get_bom_items().count(), 0)
self.assertFalse(assembly.is_bom_valid())
# Create a build for an assembly with an *invalid* BOM
set_global_setting('BUILDORDER_REQUIRE_VALID_BOM', False)
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', True)
set_global_setting('BUILDORDER_REQUIRE_LOCKED_PART', False)
bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9990')
bo.save()
# Now, require a *valid* BOM
set_global_setting('BUILDORDER_REQUIRE_VALID_BOM', True)
with self.assertRaises(ValidationError):
bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9991')
# Now, validate the BOM, and try again
assembly.validate_bom(None)
self.assertTrue(assembly.is_bom_valid())
bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9992')
# Now, try and create a build for an inactive assembly
assembly.active = False
assembly.save()
with self.assertRaises(ValidationError):
bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9993')
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', False)
Build.objects.create(part=assembly, quantity=10, reference='BO-9994')
# Check that the "locked" requirement works
set_global_setting('BUILDORDER_REQUIRE_LOCKED_PART', True)
with self.assertRaises(ValidationError):
Build.objects.create(part=assembly, quantity=10, reference='BO-9995')
assembly.locked = True
assembly.save()
Build.objects.create(part=assembly, quantity=10, reference='BO-9996')
# Check that expected quantity of new builds is created
self.assertEqual(Build.objects.count(), n + 4)
class TestBuildViews(InvenTreeTestCase):
"""Tests for Build app views."""

@@ -5,6 +5,34 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
import common.models
import common.validators
@admin.register(common.models.Attachment)
class AttachmentAdmin(admin.ModelAdmin):
"""Admin interface for Attachment objects."""
def formfield_for_dbfield(self, db_field, request, **kwargs):
"""Provide custom choices for 'model_type' field."""
if db_field.name == 'model_type':
db_field.choices = common.validators.attachment_model_options()
return super().formfield_for_dbfield(db_field, request, **kwargs)
list_display = (
'model_type',
'model_id',
'attachment',
'link',
'upload_user',
'upload_date',
)
list_filter = ['model_type', 'upload_user']
readonly_fields = ['file_size', 'upload_date', 'upload_user']
search_fields = ('content_type', 'comment')
@admin.register(common.models.ProjectCode)
@@ -16,6 +44,7 @@ class ProjectCodeAdmin(ImportExportModelAdmin):
search_fields = ('code', 'description')
@admin.register(common.models.InvenTreeSetting)
class SettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeSetting."""
@@ -28,6 +57,7 @@ class SettingsAdmin(ImportExportModelAdmin):
return []
@admin.register(common.models.InvenTreeUserSetting)
class UserSettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeUserSetting."""
@@ -40,18 +70,21 @@ class UserSettingsAdmin(ImportExportModelAdmin):
return []
@admin.register(common.models.WebhookEndpoint)
class WebhookAdmin(ImportExportModelAdmin):
"""Admin settings for Webhook."""
list_display = ('endpoint_id', 'name', 'active', 'user')
@admin.register(common.models.NotificationEntry)
class NotificationEntryAdmin(admin.ModelAdmin):
"""Admin settings for NotificationEntry."""
list_display = ('key', 'uid', 'updated')
@admin.register(common.models.NotificationMessage)
class NotificationMessageAdmin(admin.ModelAdmin):
"""Admin settings for NotificationMessage."""
@@ -70,16 +103,11 @@ class NotificationMessageAdmin(admin.ModelAdmin):
search_fields = ('name', 'category', 'message')
@admin.register(common.models.NewsFeedEntry)
class NewsFeedEntryAdmin(admin.ModelAdmin):
"""Admin settings for NewsFeedEntry."""
list_display = ('title', 'author', 'published', 'summary')
admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin)

@@ -4,26 +4,32 @@ import json
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http.response import HttpResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import csrf_exempt
import django_q.models
from django_filters import rest_framework as rest_filters
from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
import common.models
import common.serializers
from common.icons import get_icon_packs
from common.settings import get_global_setting
from generic.states.api import AllStatusViews, StatusView
from importer.mixins import DataExportViewMixin
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
@@ -491,7 +497,7 @@ class NotesImageList(ListCreateAPI):
image.save()
class ProjectCodeList(ListCreateAPI):
class ProjectCodeList(DataExportViewMixin, ListCreateAPI):
"""List view for all project codes."""
queryset = common.models.ProjectCode.objects.all()
@@ -512,7 +518,7 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
class CustomUnitList(ListCreateAPI):
class CustomUnitList(DataExportViewMixin, ListCreateAPI):
"""List view for custom units."""
queryset = common.models.CustomUnit.objects.all()
@@ -674,6 +680,83 @@ class ContentTypeModelDetail(ContentTypeDetail):
raise NotFound()
class AttachmentFilter(rest_filters.FilterSet):
"""Filterset for the AttachmentList API endpoint."""
class Meta:
"""Metaclass options."""
model = common.models.Attachment
fields = ['model_type', 'model_id', 'upload_user']
is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link')
def filter_is_link(self, queryset, name, value):
"""Filter attachments based on whether they are a link or not."""
if value:
return queryset.exclude(link=None).exclude(link='')
return queryset.filter(Q(link=None) | Q(link='')).distinct()
is_file = rest_filters.BooleanFilter(label=_('Is File'), method='filter_is_file')
def filter_is_file(self, queryset, name, value):
"""Filter attachments based on whether they are a file or not."""
if value:
return queryset.exclude(attachment=None).exclude(attachment='')
return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct()
class AttachmentList(ListCreateAPI):
"""List API endpoint for Attachment objects."""
queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
filter_backends = SEARCH_ORDER_FILTER
filterset_class = AttachmentFilter
ordering_fields = ['model_id', 'model_type', 'upload_date', 'file_size']
search_fields = ['comment', 'model_id', 'model_type']
def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()
attachment.upload_user = self.request.user
attachment.save()
class AttachmentDetail(RetrieveUpdateDestroyAPI):
"""Detail API endpoint for Attachment objects."""
queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
def destroy(self, request, *args, **kwargs):
"""Check user permissions before deleting an attachment."""
attachment = self.get_object()
if not attachment.check_permission('delete', request.user):
raise PermissionDenied(
_('User does not have permission to delete this attachment')
)
return super().destroy(request, *args, **kwargs)
@method_decorator(cache_control(public=True, max_age=86400), name='dispatch')
class IconList(ListAPI):
"""List view for available icon packages."""
serializer_class = common.serializers.IconPackageSerializer
permission_classes = [permissions.AllowAny]
def get_queryset(self):
"""Return a list of all available icon packages."""
return get_icon_packs().values()
settings_api_urls = [
# User settings
path(
@@ -742,6 +825,25 @@ common_api_urls = [
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
]),
),
# Attachments
path(
'attachment/',
include([
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(),
{'model': common.models.Attachment},
name='api-attachment-metadata',
),
path('', AttachmentDetail.as_view(), name='api-attachment-detail'),
]),
),
path('', AttachmentList.as_view(), name='api-attachment-list'),
]),
),
path(
'error-report/',
include([
@@ -862,13 +964,15 @@ common_api_urls = [
'<int:pk>/', ContentTypeDetail.as_view(), name='api-contenttype-detail'
),
path(
'<str:model>/',
'model/<str:model>/',
ContentTypeModelDetail.as_view(),
name='api-contenttype-detail-modelname',
),
path('', ContentTypeList.as_view(), name='api-contenttype-list'),
]),
),
# Icons
path('icons/', IconList.as_view(), name='api-icon-list'),
]
admin_api_urls = [

@@ -28,9 +28,7 @@ def currency_code_default():
return cached_value
try:
code = get_global_setting(
'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True
)
code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here
code = ''
@@ -61,7 +59,9 @@ def currency_codes() -> list:
"""Returns the current currency codes."""
from common.settings import get_global_setting
codes = get_global_setting('CURRENCY_CODES', '', create=False).strip()
codes = get_global_setting(
'CURRENCY_CODES', create=False, enviroment_key='INVENTREE_CURRENCY_CODES'
).strip()
if not codes:
codes = currency_codes_default_list()

@@ -51,6 +51,9 @@ class MatchFieldForm(forms.Form):
super().__init__(*args, **kwargs)
if not file_manager: # pragma: no cover
return
# Setup FileManager
file_manager.setup()
# Get columns
@@ -87,6 +90,9 @@ class MatchItemForm(forms.Form):
super().__init__(*args, **kwargs)
if not file_manager: # pragma: no cover
return
# Setup FileManager
file_manager.setup()

@@ -0,0 +1,114 @@
"""Icon utilities for InvenTree."""
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict
from django.core.exceptions import ValidationError
from django.templatetags.static import static
logger = logging.getLogger('inventree')
_icon_packs = None
class Icon(TypedDict):
"""Dict type for an icon.
Attributes:
name: The name of the icon.
category: The category of the icon.
tags: A list of tags for the icon (used for search).
variants: A dictionary of variants for the icon, where the key is the variant name and the value is the variant's unicode hex character.
"""
name: str
category: str
tags: list[str]
variants: dict[str, str]
@dataclass
class IconPack:
"""Dataclass for an icon pack.
Attributes:
name: The name of the icon pack.
prefix: The prefix used for the icon pack.
fonts: A dictionary of different font file formats for the icon pack, where the key is the css format and the value a path to the font file.
icons: A dictionary of icons in the icon pack, where the key is the icon name and the value is a dictionary of the icon's variants.
"""
name: str
prefix: str
fonts: dict[str, str]
icons: dict[str, Icon]
def get_icon_packs():
"""Return a dictionary of available icon packs including their icons."""
global _icon_packs
if _icon_packs is None:
tabler_icons_path = Path(__file__).parent.parent.joinpath(
'InvenTree/static/tabler-icons/icons.json'
)
with open(tabler_icons_path, 'r') as tabler_icons_file:
tabler_icons = json.load(tabler_icons_file)
icon_packs = [
IconPack(
name='Tabler Icons',
prefix='ti',
fonts={
'woff2': static('tabler-icons/tabler-icons.woff2'),
'woff': static('tabler-icons/tabler-icons.woff'),
'truetype': static('tabler-icons/tabler-icons.ttf'),
},
icons=tabler_icons,
)
]
from plugin import registry
for plugin in registry.with_mixin('icon_pack', active=True):
try:
icon_packs.extend(plugin.icon_packs())
except Exception as e:
logger.warning('Error loading icon pack from plugin %s: %s', plugin, e)
_icon_packs = {pack.prefix: pack for pack in icon_packs}
return _icon_packs
def reload_icon_packs():
"""Reload the icon packs."""
global _icon_packs
_icon_packs = None
get_icon_packs()
def validate_icon(icon: str):
"""Validate an icon string in the format pack:name:variant."""
try:
pack, name, variant = icon.split(':')
except ValueError:
raise ValidationError(
f'Invalid icon format: {icon}, expected: pack:name:variant'
)
packs = get_icon_packs()
if pack not in packs:
raise ValidationError(f'Invalid icon pack: {pack}')
if name not in packs[pack].icons:
raise ValidationError(f'Invalid icon name: {name}')
if variant not in packs[pack].icons[name]['variants']:
raise ValidationError(f'Invalid icon variant: {variant}')
return packs[pack], packs[pack].icons[name], variant

@@ -17,5 +17,8 @@ class Migration(migrations.Migration):
('code', models.CharField(help_text='Unique project code', max_length=50, unique=True, verbose_name='Project Code')),
('description', models.CharField(blank=True, help_text='Project description', max_length=200, verbose_name='Description')),
],
options={
'verbose_name': 'Project Code',
},
),
]

@@ -18,5 +18,8 @@ class Migration(migrations.Migration):
('symbol', models.CharField(blank=True, help_text='Optional unit symbol', max_length=10, unique=True, verbose_name='Symbol')),
('definition', models.CharField(help_text='Unit definition', max_length=50, verbose_name='Definition')),
],
options={
'verbose_name': 'Custom Unit',
},
),
]

@@ -1,5 +1,6 @@
# Generated by Django 4.2.12 on 2024-06-02 13:32
from django.conf import settings
from django.db import migrations
from moneyed import CURRENCIES
@@ -47,16 +48,20 @@ def set_currencies(apps, schema_editor):
return
value = ','.join(valid_codes)
print(f"Found existing currency codes:", value)
if not settings.TESTING:
print(f"Found existing currency codes:", value)
setting = InvenTreeSetting.objects.filter(key=key).first()
if setting:
print(f"- Updating existing setting for currency codes")
if not settings.TESTING:
print(f"- Updating existing setting for currency codes")
setting.value = value
setting.save()
else:
print(f"- Creating new setting for currency codes")
if not settings.TESTING:
print(f"- Creating new setting for currency codes")
setting = InvenTreeSetting(key=key, value=value)
setting.save()

@@ -0,0 +1,43 @@
# Generated by Django 4.2.12 on 2024-06-08 12:37
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import common.models
import common.validators
import InvenTree.fields
import InvenTree.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('common', '0024_notesimage_model_id_notesimage_model_type'),
]
operations = [
migrations.CreateModel(
name='Attachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model_id', models.PositiveIntegerField()),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=common.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='Attachment comment', max_length=250, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, help_text='Date the file was uploaded', null=True, verbose_name='Upload date')),
('file_size', models.PositiveIntegerField(default=0, help_text='File size in bytes', verbose_name='File size')),
('model_type', models.CharField(help_text='Target model type for this image', max_length=100, validators=[common.validators.validate_attachment_model_type])),
('upload_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')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'))
],
bases=(InvenTree.models.PluginValidationMixin, models.Model),
options={
'verbose_name': 'Attachment',
}
),
]

@@ -0,0 +1,122 @@
# Generated by Django 4.2.12 on 2024-06-08 12:38
from django.db import migrations
from django.core.files.storage import default_storage
def get_legacy_models():
"""Return a set of legacy attachment models."""
# Legacy attachment types to convert:
# app_label, table name, target model, model ref
return [
('build', 'BuildOrderAttachment', 'build', 'build'),
('company', 'CompanyAttachment', 'company', 'company'),
('company', 'ManufacturerPartAttachment', 'manufacturerpart', 'manufacturer_part'),
('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'),
('order', 'SalesOrderAttachment', 'salesorder', 'order'),
('order', 'ReturnOrderAttachment', 'returnorder', 'order'),
('part', 'PartAttachment', 'part', 'part'),
('stock', 'StockItemAttachment', 'stockitem', 'stock_item')
]
def update_attachments(apps, schema_editor):
"""Migrate any existing attachment models to the new attachment table."""
Attachment = apps.get_model('common', 'attachment')
N = 0
for app, model, target_model, model_ref in get_legacy_models():
LegacyAttachmentModel = apps.get_model(app, model)
if LegacyAttachmentModel.objects.count() == 0:
continue
to_create = []
for attachment in LegacyAttachmentModel.objects.all():
# Find the size of the file (if exists)
if attachment.attachment and default_storage.exists(attachment.attachment.name):
try:
file_size = default_storage.size(attachment.attachment.name)
except NotImplementedError:
file_size = 0
else:
file_size = 0
to_create.append(
Attachment(
model_type=target_model,
model_id=getattr(attachment, model_ref).pk,
attachment=attachment.attachment,
link=attachment.link,
comment=attachment.comment,
upload_date=attachment.upload_date,
upload_user=attachment.user,
file_size=file_size
)
)
if len(to_create) > 0:
print(f"Migrating {len(to_create)} attachments for the legacy '{model}' model.")
Attachment.objects.bulk_create(to_create)
N += len(to_create)
# Check the correct number of Attachment objects has been created
assert(N == Attachment.objects.count())
def reverse_attachments(apps, schema_editor):
"""Reverse data migration, and map new Attachment model back to legacy models."""
Attachment = apps.get_model('common', 'attachment')
N = 0
for app, model, target_model, model_ref in get_legacy_models():
LegacyAttachmentModel = apps.get_model(app, model)
to_create = []
for attachment in Attachment.objects.filter(model_type=target_model):
TargetModel = apps.get_model(app, target_model)
data = {
'attachment': attachment.attachment,
'link': attachment.link,
'comment': attachment.comment,
'upload_date': attachment.upload_date,
'user': attachment.upload_user,
model_ref: TargetModel.objects.get(pk=attachment.model_id)
}
to_create.append(LegacyAttachmentModel(**data))
if len(to_create) > 0:
print(f"Reversing {len(to_create)} attachments for the legacy '{model}' model.")
LegacyAttachmentModel.objects.bulk_create(to_create)
N += len(to_create)
# Check the correct number of LegacyAttachmentModel objects has been created
assert(N == Attachment.objects.count())
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0025_attachment'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.RunPython(update_attachments, reverse_code=reverse_attachments),
]

@@ -0,0 +1,18 @@
# Generated by Django 4.2.12 on 2024-07-04 10:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0026_auto_20240608_1238'),
]
operations = [
migrations.AlterField(
model_name='customunit',
name='symbol',
field=models.CharField(blank=True, help_text='Optional unit symbol', max_length=10, verbose_name='Symbol'),
),
]

@@ -0,0 +1,39 @@
# Generated by Django 4.2.12 on 2024-07-04 10:23
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def migrate_userthemes(apps, schema_editor):
"""Mgrate text-based user references to ForeignKey references."""
ColorTheme = apps.get_model("common", "ColorTheme")
User = apps.get_model(settings.AUTH_USER_MODEL)
for theme in ColorTheme.objects.all():
try:
theme.user_obj = User.objects.get(username=theme.user)
theme.save()
except User.DoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("common", "0027_alter_customunit_symbol"),
]
operations = [
migrations.AddField(
model_name="colortheme",
name="user_obj",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
migrations.RunPython(migrate_userthemes, migrations.RunPython.noop),
]

@@ -9,9 +9,11 @@ import hmac
import json
import logging
import os
import sys
import uuid
from datetime import timedelta, timezone
from enum import Enum
from io import BytesIO
from secrets import compare_digest
from typing import Any, Callable, TypedDict, Union
@@ -23,6 +25,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
from django.db import models, transaction
from django.db.models.signals import post_delete, post_save
@@ -35,6 +38,7 @@ from django.utils.translation import gettext_lazy as _
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from rest_framework.exceptions import PermissionDenied
from taggit.managers import TaggableManager
import build.validators
import common.currency
@@ -46,12 +50,25 @@ import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
import order.validators
import plugin.base.barcodes.helper
import report.helpers
import users.models
from InvenTree.sanitizer import sanitize_svg
from plugin import registry
logger = logging.getLogger('inventree')
if sys.version_info >= (3, 11):
from typing import NotRequired
else:
class NotRequired: # pragma: no cover
"""NotRequired type helper is only supported with Python 3.11+."""
def __class_getitem__(cls, item):
"""Return the item."""
return item
class MetaMixin(models.Model):
"""A base class for InvenTree models to include shared meta fields.
@@ -112,6 +129,11 @@ class BaseURLValidator(URLValidator):
class ProjectCode(InvenTree.models.InvenTreeMetadataModel):
"""A ProjectCode is a unique identifier for a project."""
class Meta:
"""Class options for the ProjectCode model."""
verbose_name = _('Project Code')
@staticmethod
def get_api_url():
"""Return the API URL for this model."""
@@ -549,25 +571,25 @@ class BaseInvenTreeSetting(models.Model):
"""
key = str(key).strip().upper()
filters = {
'key__iexact': key,
# Optionally filter by other keys
**cls.get_filters(**kwargs),
}
# Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True)
# Specify if cache lookup should be performed
do_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED)
# Prevent saving to the database during data import
if InvenTree.ready.isImportingData():
create = False
do_cache = False
filters = {
'key__iexact': key,
# Optionally filter by other keys
**cls.get_filters(**kwargs),
}
# Prevent saving to the database during migrations
if InvenTree.ready.isRunningMigrations():
# Prevent saving to the database during certain operations
if (
InvenTree.ready.isImportingData()
or InvenTree.ready.isRunningMigrations()
or InvenTree.ready.isRebuildingData()
or InvenTree.ready.isRunningBackup()
):
create = False
do_cache = False
@@ -594,33 +616,21 @@ class BaseInvenTreeSetting(models.Model):
setting = None
# Setting does not exist! (Try to create it)
if not setting:
# Prevent creation of new settings objects when importing data
if (
InvenTree.ready.isImportingData()
or not InvenTree.ready.canAppAccessDatabase(
allow_test=True, allow_shell=True
)
):
create = False
if not setting and create:
# Attempt to create a new settings object
default_value = cls.get_setting_default(key, **kwargs)
setting = cls(key=key, value=default_value, **kwargs)
if create:
# Attempt to create a new settings object
default_value = cls.get_setting_default(key, **kwargs)
setting = cls(key=key, value=default_value, **kwargs)
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save(**kwargs)
except (IntegrityError, OperationalError, ProgrammingError):
# It might be the case that the database isn't created yet
pass
except ValidationError:
# The setting failed validation - might be due to duplicate keys
pass
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save(**kwargs)
except (IntegrityError, OperationalError, ProgrammingError):
# It might be the case that the database isn't created yet
pass
except ValidationError:
# The setting failed validation - might be due to duplicate keys
pass
if setting and do_cache:
# Cache this setting object
@@ -694,6 +704,15 @@ class BaseInvenTreeSetting(models.Model):
if change_user is not None and not change_user.is_staff:
return
# Do not write to the database under certain conditions
if (
InvenTree.ready.isImportingData()
or InvenTree.ready.isRunningMigrations()
or InvenTree.ready.isRebuildingData()
or InvenTree.ready.isRunningBackup()
):
return
attempts = int(kwargs.get('attempts', 3))
filters = {
@@ -1161,7 +1180,7 @@ class InvenTreeSettingsKeyType(SettingsKeyType):
requires_restart: If True, a server restart is required after changing the setting
"""
requires_restart: bool
requires_restart: NotRequired[bool]
class InvenTreeSetting(BaseInvenTreeSetting):
@@ -1390,12 +1409,30 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': True,
'validator': bool,
},
'BARCODE_SHOW_TEXT': {
'name': _('Barcode Show Data'),
'description': _('Display barcode data in browser as text'),
'default': False,
'validator': bool,
},
'BARCODE_GENERATION_PLUGIN': {
'name': _('Barcode Generation Plugin'),
'description': _('Plugin to use for internal barcode data generation'),
'choices': plugin.base.barcodes.helper.barcode_plugins,
'default': 'inventreebarcode',
},
'PART_ENABLE_REVISION': {
'name': _('Part Revisions'),
'description': _('Enable revision field for Part'),
'validator': bool,
'default': True,
},
'PART_REVISION_ASSEMBLY_ONLY': {
'name': _('Assembly Revision Only'),
'description': _('Only allow revisions for assembly parts'),
'validator': bool,
'default': False,
},
'PART_ALLOW_DELETE_FROM_ASSEMBLY': {
'name': _('Allow Deletion from Assembly'),
'description': _('Allow deletion of parts which are used in an assembly'),
@@ -1521,6 +1558,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Part Category Default Icon'),
'description': _('Part category default icon (empty means no icon)'),
'default': '',
'validator': common.validators.validate_icon,
},
'PART_PARAMETER_ENFORCE_UNITS': {
'name': _('Enforce Parameter Units'),
@@ -1742,6 +1780,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Stock Location Default Icon'),
'description': _('Stock location default icon (empty means no icon)'),
'default': '',
'validator': common.validators.validate_icon,
},
'STOCK_SHOW_INSTALLED_ITEMS': {
'name': _('Show Installed Stock Items'),
@@ -1779,6 +1818,34 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'BUILDORDER_REQUIRE_ACTIVE_PART': {
'name': _('Require Active Part'),
'description': _('Prevent build order creation for inactive parts'),
'default': False,
'validator': bool,
},
'BUILDORDER_REQUIRE_LOCKED_PART': {
'name': _('Require Locked Part'),
'description': _('Prevent build order creation for unlocked parts'),
'default': False,
'validator': bool,
},
'BUILDORDER_REQUIRE_VALID_BOM': {
'name': _('Require Valid BOM'),
'description': _(
'Prevent build order creation unless BOM has been validated'
),
'default': False,
'validator': bool,
},
'BUILDORDER_REQUIRE_CLOSED_CHILDS': {
'name': _('Require Closed Child Orders'),
'description': _(
'Prevent build order completion until all child orders are closed'
),
'default': False,
'validator': bool,
},
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
'name': _('Block Until Tests Pass'),
'description': _(
@@ -1908,6 +1975,38 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'LOGIN_ENABLE_SSO_GROUP_SYNC': {
'name': _('Enable SSO group sync'),
'description': _(
'Enable synchronizing InvenTree groups with groups provided by the IdP'
),
'default': False,
'validator': bool,
},
'SSO_GROUP_KEY': {
'name': _('SSO group key'),
'description': _(
'The name of the groups claim attribute provided by the IdP'
),
'default': 'groups',
'validator': str,
},
'SSO_GROUP_MAP': {
'name': _('SSO group map'),
'description': _(
'A mapping from SSO groups to local InvenTree groups. If the local group does not exist, it will be created.'
),
'validator': json.loads,
'default': '{}',
},
'SSO_REMOVE_GROUPS': {
'name': _('Remove groups outside of SSO'),
'description': _(
'Whether groups assigned to the user should be removed if they are not backend by the IdP. Disabling this setting might cause security issues'
),
'default': True,
'validator': bool,
},
'LOGIN_MAIL_REQUIRED': {
'name': _('Email required'),
'description': _('Require user to supply mail on signup'),
@@ -1944,7 +2043,9 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'SIGNUP_GROUP': {
'name': _('Group on signup'),
'description': _('Group to which new users are assigned on registration'),
'description': _(
'Group to which new users are assigned on registration. If SSO group sync is enabled, this group is only set if no group can be assigned from the IdP.'
),
'default': '',
'choices': settings_group_options,
},
@@ -2425,36 +2526,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': [int, MinValueValidator(0)],
'default': 100,
},
'DEFAULT_PART_LABEL_TEMPLATE': {
'name': _('Default part label template'),
'description': _('The part label template to be automatically selected'),
'validator': [int],
'default': '',
},
'DEFAULT_ITEM_LABEL_TEMPLATE': {
'name': _('Default stock item template'),
'description': _(
'The stock item label template to be automatically selected'
),
'validator': [int],
'default': '',
},
'DEFAULT_LOCATION_LABEL_TEMPLATE': {
'name': _('Default stock location label template'),
'description': _(
'The stock location label template to be automatically selected'
),
'validator': [int],
'default': '',
},
'DEFAULT_LINE_LABEL_TEMPLATE': {
'name': _('Default build line label template'),
'description': _(
'The build line label template to be automatically selected'
),
'validator': [int],
'default': '',
},
'NOTIFICATION_ERROR_REPORT': {
'name': _('Receive error reports'),
'description': _('Receive notifications for system errors'),
@@ -2542,18 +2613,27 @@ class ColorTheme(models.Model):
name = models.CharField(max_length=20, default='', blank=True)
user = models.CharField(max_length=150, unique=True)
user_obj = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
@classmethod
def get_color_themes_choices(cls):
"""Get all color themes from static folder."""
if not django_settings.STATIC_COLOR_THEMES_DIR.exists():
logger.error('Theme directory does not exist')
color_theme_dir = (
django_settings.STATIC_COLOR_THEMES_DIR
if django_settings.STATIC_COLOR_THEMES_DIR.exists()
else django_settings.BASE_DIR.joinpath(
'InvenTree', 'static', 'css', 'color-themes'
)
)
if not color_theme_dir.exists():
logger.error(f'Theme directory "{color_theme_dir}" does not exist')
return []
# Get files list from css/color-themes/ folder
files_list = []
for file in django_settings.STATIC_COLOR_THEMES_DIR.iterdir():
for file in color_theme_dir.iterdir():
files_list.append([file.stem, file.suffix])
# Get color themes choices (CSS sheets)
@@ -2992,6 +3072,11 @@ class CustomUnit(models.Model):
https://pint.readthedocs.io/en/stable/advanced/defining.html
"""
class Meta:
"""Class meta options."""
verbose_name = _('Custom Unit')
def fmt_string(self):
"""Construct a unit definition string e.g. 'dog_year = 52 * day = dy'."""
fmt = f'{self.name} = {self.definition}'
@@ -3001,6 +3086,18 @@ class CustomUnit(models.Model):
return fmt
def validate_unique(self, exclude=None) -> None:
"""Ensure that the custom unit is unique."""
super().validate_unique(exclude)
if self.symbol:
if (
CustomUnit.objects.filter(symbol=self.symbol)
.exclude(pk=self.pk)
.exists()
):
raise ValidationError({'symbol': _('Unit symbol must be unique')})
def clean(self):
"""Validate that the provided custom unit is indeed valid."""
super().clean()
@@ -3042,7 +3139,6 @@ class CustomUnit(models.Model):
max_length=10,
verbose_name=_('Symbol'),
help_text=_('Optional unit symbol'),
unique=True,
blank=True,
)
@@ -3062,3 +3158,184 @@ def after_custom_unit_updated(sender, instance, **kwargs):
from InvenTree.conversion import reload_unit_registry
reload_unit_registry()
def rename_attachment(instance, filename):
"""Callback function to rename an uploaded attachment file.
Arguments:
- instance: The Attachment instance
- filename: The original filename of the uploaded file
Returns:
- The new filename for the uploaded file, e.g. 'attachments/<model_type>/<model_id>/<filename>'
"""
# Remove any illegal characters from the filename
illegal_chars = '\'"\\`~#|!@#$%^&*()[]{}<>?;:+=,'
for c in illegal_chars:
filename = filename.replace(c, '')
filename = os.path.basename(filename)
# Generate a new filename for the attachment
return os.path.join(
'attachments', str(instance.model_type), str(instance.model_id), filename
)
class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
"""Class which represents an uploaded file attachment.
An attachment can be either an uploaded file, or an external URL.
Attributes:
attachment: The uploaded file
url: An external URL
comment: A comment or description for the attachment
user: The user who uploaded the attachment
upload_date: The date the attachment was uploaded
file_size: The size of the uploaded file
metadata: Arbitrary metadata for the attachment (inherit from MetadataMixin)
tags: Tags for the attachment
"""
class Meta:
"""Metaclass options."""
verbose_name = _('Attachment')
def save(self, *args, **kwargs):
"""Custom 'save' method for the Attachment model.
- Record the file size of the uploaded attachment (if applicable)
- Ensure that the 'content_type' and 'object_id' fields are set
- Run extra validations
"""
# Either 'attachment' or 'link' must be specified!
if not self.attachment and not self.link:
raise ValidationError({
'attachment': _('Missing file'),
'link': _('Missing external link'),
})
if self.attachment:
if self.attachment.name.lower().endswith('.svg'):
self.attachment.file.file = self.clean_svg(self.attachment)
else:
self.file_size = 0
super().save(*args, **kwargs)
# Update file size
if self.file_size == 0 and self.attachment:
# Get file size
if default_storage.exists(self.attachment.name):
try:
self.file_size = default_storage.size(self.attachment.name)
except Exception:
pass
if self.file_size != 0:
super().save()
def clean_svg(self, field):
"""Sanitize SVG file before saving."""
cleaned = sanitize_svg(field.file.read())
return BytesIO(bytes(cleaned, 'utf8'))
def __str__(self):
"""Human name for attachment."""
if self.attachment is not None:
return os.path.basename(self.attachment.name)
return str(self.link)
model_type = models.CharField(
max_length=100,
validators=[common.validators.validate_attachment_model_type],
help_text=_('Target model type for this image'),
)
model_id = models.PositiveIntegerField()
attachment = models.FileField(
upload_to=rename_attachment,
verbose_name=_('Attachment'),
help_text=_('Select file to attach'),
blank=True,
null=True,
)
link = InvenTree.fields.InvenTreeURLField(
blank=True,
null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL'),
)
comment = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Comment'),
help_text=_('Attachment comment'),
)
upload_user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('User'),
help_text=_('User'),
)
upload_date = models.DateField(
auto_now_add=True,
null=True,
blank=True,
verbose_name=_('Upload date'),
help_text=_('Date the file was uploaded'),
)
file_size = models.PositiveIntegerField(
default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
)
tags = TaggableManager(blank=True)
@property
def basename(self):
"""Base name/path for attachment."""
if self.attachment:
return os.path.basename(self.attachment.name)
return None
def fully_qualified_url(self):
"""Return a 'fully qualified' URL for this attachment.
- If the attachment is a link to an external resource, return the link
- If the attachment is an uploaded file, return the fully qualified media URL
"""
if self.link:
return self.link
if self.attachment:
import InvenTree.helpers_model
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
return InvenTree.helpers_model.construct_absolute_url(media_url)
return ''
def check_permission(self, permission, user):
"""Check if the user has the required permission for this attachment."""
from InvenTree.models import InvenTreeAttachmentMixin
model_class = common.validators.attachment_model_class_from_label(
self.model_type
)
if not issubclass(model_class, InvenTreeAttachmentMixin):
raise ValidationError(_('Invalid model type specified for attachment'))
return model_class.check_attachment_permission(permission, user)

@@ -9,13 +9,20 @@ import django_q.models
from error_report.models import Error
from flags.state import flag_state
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from taggit.serializers import TagListSerializerField
import common.models as common_models
import common.validators
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
UserSerializer,
)
from plugin import registry as plugin_registry
from users.serializers import OwnerSerializer
@@ -288,7 +295,8 @@ class NotesImageSerializer(InvenTreeModelSerializer):
image = InvenTreeImageSerializerField(required=True)
class ProjectCodeSerializer(InvenTreeModelSerializer):
@register_importer()
class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the ProjectCode model."""
class Meta:
@@ -336,7 +344,8 @@ class ContentTypeSerializer(serializers.Serializer):
return obj.app_label in plugin_registry.installed_apps
class CustomUnitSerializer(InvenTreeModelSerializer):
@register_importer()
class CustomUnitSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""DRF serializer for CustomUnit model."""
class Meta:
@@ -474,3 +483,103 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
pk = serializers.CharField(source='id', read_only=True)
result = serializers.CharField()
class AttachmentSerializer(InvenTreeModelSerializer):
"""Serializer class for the Attachment model."""
class Meta:
"""Serializer metaclass."""
model = common_models.Attachment
fields = [
'pk',
'attachment',
'filename',
'link',
'comment',
'upload_date',
'upload_user',
'user_detail',
'file_size',
'model_type',
'model_id',
'tags',
]
read_only_fields = ['pk', 'file_size', 'upload_date', 'upload_user', 'filename']
def __init__(self, *args, **kwargs):
"""Override the model_type field to provide dynamic choices."""
super().__init__(*args, **kwargs)
if len(self.fields['model_type'].choices) == 0:
self.fields[
'model_type'
].choices = common.validators.attachment_model_options()
tags = TagListSerializerField(required=False)
user_detail = UserSerializer(source='upload_user', read_only=True, many=False)
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True)
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'), required=False, source='basename', allow_blank=False
)
upload_date = serializers.DateField(read_only=True)
# Note: The choices are overridden at run-time on class initialization
model_type = serializers.ChoiceField(
label=_('Model Type'),
choices=common.validators.attachment_model_options(),
required=True,
allow_blank=False,
allow_null=False,
)
def save(self):
"""Override the save method to handle the model_type field."""
from InvenTree.models import InvenTreeAttachmentMixin
model_type = self.validated_data.get('model_type', None)
# Ensure that the user has permission to attach files to the specified model
user = self.context.get('request').user
target_model_class = common.validators.attachment_model_class_from_label(
model_type
)
if not issubclass(target_model_class, InvenTreeAttachmentMixin):
raise PermissionDenied(_('Invalid model type specified for attachment'))
# Check that the user has the required permissions to attach files to the target model
if not target_model_class.check_attachment_permission('change', user):
raise PermissionDenied(
_(
'User does not have permission to create or edit attachments for this model'
)
)
return super().save()
class IconSerializer(serializers.Serializer):
"""Serializer for an icon."""
name = serializers.CharField()
category = serializers.CharField()
tags = serializers.ListField(child=serializers.CharField())
variants = serializers.DictField(child=serializers.CharField())
class IconPackageSerializer(serializers.Serializer):
"""Serializer for a list of icons."""
name = serializers.CharField()
prefix = serializers.CharField()
fonts = serializers.DictField(child=serializers.CharField())
icons = serializers.DictField(child=IconSerializer())

@@ -1,11 +1,19 @@
"""User-configurable settings for the common app."""
from os import environ
def get_global_setting(key, backup_value=None, **kwargs):
def get_global_setting(key, backup_value=None, enviroment_key=None, **kwargs):
"""Return the value of a global setting using the provided key."""
from common.models import InvenTreeSetting
kwargs['backup_value'] = backup_value
if enviroment_key:
value = environ.get(enviroment_key)
if value:
return value
if backup_value is not None:
kwargs['backup_value'] = backup_value
return InvenTreeSetting.get_setting(key, **kwargs)
@@ -25,7 +33,9 @@ def get_user_setting(key, user, backup_value=None, **kwargs):
from common.models import InvenTreeUserSetting
kwargs['user'] = user
kwargs['backup_value'] = backup_value
if backup_value is not None:
kwargs['backup_value'] = backup_value
return InvenTreeUserSetting.get_setting(key, **kwargs)

@@ -0,0 +1,210 @@
"""Data migration unit tests for the 'common' app."""
import io
from django.core.files.base import ContentFile
from django_test_migrations.contrib.unittest_case import MigratorTestCase
from InvenTree import unit_test
def get_legacy_models():
"""Return a set of legacy attachment models."""
# Legacy attachment types to convert:
# app_label, table name, target model, model ref
return [
('build', 'BuildOrderAttachment', 'build', 'build'),
('company', 'CompanyAttachment', 'company', 'company'),
(
'company',
'ManufacturerPartAttachment',
'manufacturerpart',
'manufacturer_part',
),
('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'),
('order', 'SalesOrderAttachment', 'salesorder', 'order'),
('order', 'ReturnOrderAttachment', 'returnorder', 'order'),
('part', 'PartAttachment', 'part', 'part'),
('stock', 'StockItemAttachment', 'stockitem', 'stock_item'),
]
def generate_attachment():
"""Generate a file attachment object for test upload."""
file_object = io.StringIO('Some dummy data')
file_object.seek(0)
return ContentFile(file_object.getvalue(), 'test.txt')
class TestForwardMigrations(MigratorTestCase):
"""Test entire schema migration sequence for the common app."""
migrate_from = ('common', '0024_notesimage_model_id_notesimage_model_type')
migrate_to = ('common', unit_test.getNewestMigrationFile('common'))
def prepare(self):
"""Create initial data.
Legacy attachment model types are:
- BuildOrderAttachment
- CompanyAttachment
- ManufacturerPartAttachment
- PurchaseOrderAttachment
- SalesOrderAttachment
- ReturnOrderAttachment
- PartAttachment
- StockItemAttachment
"""
# Dummy MPPT data
tree = {'tree_id': 0, 'level': 0, 'lft': 0, 'rght': 0}
# BuildOrderAttachment
Part = self.old_state.apps.get_model('part', 'Part')
Build = self.old_state.apps.get_model('build', 'Build')
part = Part.objects.create(
name='Test Part',
description='Test Part Description',
active=True,
assembly=True,
purchaseable=True,
**tree,
)
build = Build.objects.create(part=part, title='Test Build', quantity=10, **tree)
PartAttachment = self.old_state.apps.get_model('part', 'PartAttachment')
PartAttachment.objects.create(
part=part, attachment=generate_attachment(), comment='Test file attachment'
)
PartAttachment.objects.create(
part=part, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(PartAttachment.objects.count(), 2)
BuildOrderAttachment = self.old_state.apps.get_model(
'build', 'BuildOrderAttachment'
)
BuildOrderAttachment.objects.create(
build=build, link='http://example.com', comment='Test comment'
)
BuildOrderAttachment.objects.create(
build=build, attachment=generate_attachment(), comment='a test file'
)
self.assertEqual(BuildOrderAttachment.objects.count(), 2)
StockItem = self.old_state.apps.get_model('stock', 'StockItem')
StockItemAttachment = self.old_state.apps.get_model(
'stock', 'StockItemAttachment'
)
item = StockItem.objects.create(part=part, quantity=10, **tree)
StockItemAttachment.objects.create(
stock_item=item,
attachment=generate_attachment(),
comment='Test file attachment',
)
StockItemAttachment.objects.create(
stock_item=item, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(StockItemAttachment.objects.count(), 2)
Company = self.old_state.apps.get_model('company', 'Company')
CompanyAttachment = self.old_state.apps.get_model(
'company', 'CompanyAttachment'
)
company = Company.objects.create(
name='Test Company',
description='Test Company Description',
is_customer=True,
is_manufacturer=True,
is_supplier=True,
)
CompanyAttachment.objects.create(
company=company,
attachment=generate_attachment(),
comment='Test file attachment',
)
CompanyAttachment.objects.create(
company=company, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(CompanyAttachment.objects.count(), 2)
PurchaseOrder = self.old_state.apps.get_model('order', 'PurchaseOrder')
PurchaseOrderAttachment = self.old_state.apps.get_model(
'order', 'PurchaseOrderAttachment'
)
po = PurchaseOrder.objects.create(
reference='PO-12345',
supplier=company,
description='Test Purchase Order Description',
)
PurchaseOrderAttachment.objects.create(
order=po, attachment=generate_attachment(), comment='Test file attachment'
)
PurchaseOrderAttachment.objects.create(
order=po, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(PurchaseOrderAttachment.objects.count(), 2)
SalesOrder = self.old_state.apps.get_model('order', 'SalesOrder')
SalesOrderAttachment = self.old_state.apps.get_model(
'order', 'SalesOrderAttachment'
)
so = SalesOrder.objects.create(
reference='SO-12345',
customer=company,
description='Test Sales Order Description',
)
SalesOrderAttachment.objects.create(
order=so, attachment=generate_attachment(), comment='Test file attachment'
)
SalesOrderAttachment.objects.create(
order=so, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(SalesOrderAttachment.objects.count(), 2)
ReturnOrder = self.old_state.apps.get_model('order', 'ReturnOrder')
ReturnOrderAttachment = self.old_state.apps.get_model(
'order', 'ReturnOrderAttachment'
)
ro = ReturnOrder.objects.create(
reference='RO-12345',
customer=company,
description='Test Return Order Description',
)
ReturnOrderAttachment.objects.create(
order=ro, attachment=generate_attachment(), comment='Test file attachment'
)
ReturnOrderAttachment.objects.create(
order=ro, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(ReturnOrderAttachment.objects.count(), 2)
def test_items_exist(self):
"""Test to ensure that the attachments are correctly migrated."""
Attachment = self.new_state.apps.get_model('common', 'Attachment')
self.assertEqual(Attachment.objects.count(), 14)
for model in [
'build',
'company',
'purchaseorder',
'returnorder',
'salesorder',
'part',
'stockitem',
]:
self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2)

@@ -1 +0,0 @@
"""Unit tests for the views associated with the 'common' app."""

@@ -11,6 +11,8 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.test.utils import override_settings
@@ -18,14 +20,17 @@ from django.urls import reverse
import PIL
import common.validators
from common.settings import get_global_setting, set_global_setting
from InvenTree.helpers import str2bool
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin
from part.models import Part
from plugin import registry
from plugin.models import NotificationUserSetting
from .api import WebhookView
from .models import (
Attachment,
ColorTheme,
CustomUnit,
InvenTreeSetting,
@@ -41,6 +46,131 @@ from .models import (
CONTENT_TYPE_JSON = 'application/json'
class AttachmentTest(InvenTreeAPITestCase):
"""Unit tests for the 'Attachment' model."""
fixtures = ['part', 'category', 'location']
def generate_file(self, fn: str):
"""Generate an attachment file object."""
file_object = io.StringIO('Some dummy data')
file_object.seek(0)
return ContentFile(file_object.getvalue(), fn)
def test_filename_validation(self):
"""Test that the filename validation works as expected.
The django file-upload mechanism should sanitize filenames correctly.
"""
part = Part.objects.first()
filenames = {
'test.txt': 'test.txt',
'r####at.mp4': 'rat.mp4',
'../../../win32.dll': 'win32.dll',
'ABC!@#$%^&&&&&&&)-XYZ-(**&&&\\/QqQ.sqlite': 'QqQ.sqlite',
'/var/log/inventree.log': 'inventree.log',
'c:\\Users\\admin\\passwd.txt': 'cUsersadminpasswd.txt',
'8&&&8.txt': '88.txt',
}
for fn, expected in filenames.items():
attachment = Attachment.objects.create(
attachment=self.generate_file(fn),
comment=f'Testing filename: {fn}',
model_type='part',
model_id=part.pk,
)
expected_path = f'attachments/part/{part.pk}/{expected}'
self.assertEqual(attachment.attachment.name, expected_path)
self.assertEqual(attachment.file_size, 15)
self.assertEqual(part.attachments.count(), len(filenames.keys()))
# Delete any attachments after the test is completed
for attachment in part.attachments.all():
path = attachment.attachment.name
attachment.delete()
# Remove uploaded files to prevent them sticking around
if default_storage.exists(path):
default_storage.delete(path)
self.assertEqual(
Attachment.objects.filter(model_type='part', model_id=part.pk).count(), 0
)
def test_mixin(self):
"""Test that the mixin class works as expected."""
part = Part.objects.first()
self.assertEqual(part.attachments.count(), 0)
part.create_attachment(
attachment=self.generate_file('test.txt'), comment='Hello world'
)
self.assertEqual(part.attachments.count(), 1)
attachment = part.attachments.first()
self.assertEqual(attachment.comment, 'Hello world')
self.assertIn(f'attachments/part/{part.pk}/test', attachment.attachment.name)
def test_upload_via_api(self):
"""Test that we can upload attachments via the API."""
part = Part.objects.first()
url = reverse('api-attachment-list')
data = {
'model_type': 'part',
'model_id': part.pk,
'link': 'https://www.google.com',
'comment': 'Some appropriate comment',
}
# Start without appropriate permissions
# User must have 'part.change' to upload an attachment against a Part instance
self.logout()
self.user.is_staff = False
self.user.is_superuser = False
self.user.save()
self.clearRoles()
# Check without login (401)
response = self.post(url, data, expected_code=401)
self.login()
response = self.post(url, data, expected_code=403)
self.assertIn(
'User does not have permission to create or edit attachments for this model',
str(response.data['detail']),
)
# Add the required permission
self.assignRole('part.change')
# Upload should now work!
response = self.post(url, data, expected_code=201)
# Try to delete the attachment via API (should fail)
attachment = part.attachments.first()
url = reverse('api-attachment-detail', kwargs={'pk': attachment.pk})
response = self.delete(url, expected_code=403)
self.assertIn(
'User does not have permission to delete this attachment',
str(response.data['detail']),
)
# Assign 'delete' permission to 'part' model
self.assignRole('part.delete')
response = self.delete(url, expected_code=204)
class SettingsTest(InvenTreeTestCase):
"""Tests for the 'settings' model."""
@@ -277,8 +407,6 @@ class SettingsTest(InvenTreeTestCase):
@override_settings(SITE_URL=None, PLUGIN_TESTING=True, PLUGIN_TESTING_SETUP=True)
def test_defaults(self):
"""Populate the settings with default values."""
N = len(InvenTreeSetting.SETTINGS.keys())
for key in InvenTreeSetting.SETTINGS.keys():
value = InvenTreeSetting.get_setting_default(key)
@@ -973,7 +1101,6 @@ class CommonTest(InvenTreeAPITestCase):
def test_restart_flag(self):
"""Test that the restart flag is reset on start."""
import common.models
from plugin import registry
# set flag true
@@ -1070,20 +1197,10 @@ class ColorThemeTest(TestCase):
def test_choices(self):
"""Test that default choices are returned."""
result = ColorTheme.get_color_themes_choices()
# skip due to directories not being set up
if not result:
return # pragma: no cover
self.assertIn(('default', 'Default'), result)
def test_valid_choice(self):
"""Check that is_valid_choice works correctly."""
result = ColorTheme.get_color_themes_choices()
# skip due to directories not being set up
if not result:
return # pragma: no cover
# check wrong reference
self.assertFalse(ColorTheme.is_valid_choice('abcdd'))
@@ -1247,7 +1364,7 @@ class ProjectCodesTest(InvenTreeAPITestCase):
)
self.assertIn(
'project code with this Project Code already exists',
'Project Code with this Project Code already exists',
str(response.data['code']),
)
@@ -1405,3 +1522,44 @@ class ContentTypeAPITest(InvenTreeAPITestCase):
reverse('api-contenttype-detail-modelname', kwargs={'model': None}),
expected_code=404,
)
class IconAPITest(InvenTreeAPITestCase):
"""Unit tests for the Icons API."""
def test_list(self):
"""Test API list functionality."""
response = self.get(reverse('api-icon-list'), expected_code=200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['prefix'], 'ti')
self.assertEqual(response.data[0]['name'], 'Tabler Icons')
for font_format in ['woff2', 'woff', 'truetype']:
self.assertIn(font_format, response.data[0]['fonts'])
self.assertGreater(len(response.data[0]['icons']), 1000)
class ValidatorsTest(TestCase):
"""Unit tests for the custom validators."""
def test_validate_icon(self):
"""Test the validate_icon function."""
common.validators.validate_icon('')
common.validators.validate_icon(None)
with self.assertRaises(ValidationError):
common.validators.validate_icon('invalid')
with self.assertRaises(ValidationError):
common.validators.validate_icon('my:package:non-existing')
with self.assertRaises(ValidationError):
common.validators.validate_icon(
'ti:my-non-existing-icon:non-existing-variant'
)
with self.assertRaises(ValidationError):
common.validators.validate_icon('ti:package:non-existing-variant')
common.validators.validate_icon('ti:package:outline')

@@ -1,13 +1,53 @@
"""Validation helpers for common models."""
import re
from typing import Union
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import common.icons
from common.settings import get_global_setting
def attachment_model_types():
"""Return a list of valid attachment model choices."""
import InvenTree.models
return list(
InvenTree.helpers_model.getModelsWithMixin(
InvenTree.models.InvenTreeAttachmentMixin
)
)
def attachment_model_options():
"""Return a list of options for models which support attachments."""
return [
(model.__name__.lower(), model._meta.verbose_name)
for model in attachment_model_types()
]
def attachment_model_class_from_label(label: str):
"""Return the model class for the given label."""
if not label:
raise ValidationError(_('No attachment model type provided'))
for model in attachment_model_types():
if model.__name__.lower() == label.lower():
return model
raise ValidationError(_('Invalid attachment model type') + f": '{label}'")
def validate_attachment_model_type(value):
"""Ensure that the provided attachment model is valid."""
model_names = [el[0] for el in attachment_model_options()]
if value not in model_names:
raise ValidationError('Model type does not support attachments')
def validate_notes_model_type(value):
"""Ensure that the provided model type is valid.
@@ -65,3 +105,11 @@ def validate_email_domains(setting):
raise ValidationError(_('An empty domain is not allowed.'))
if not re.match(r'^@[a-zA-Z0-9\.\-_]+$', domain):
raise ValidationError(_(f'Invalid domain name: {domain}'))
def validate_icon(name: Union[str, None]):
"""Validate the provided icon name, and ignore if empty."""
if name == '' or name is None:
return
common.icons.validate_icon(name)

@@ -6,6 +6,8 @@ from import_export import widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
import company.serializers
import importer.admin
from InvenTree.admin import InvenTreeResource
from part.models import Part
@@ -14,7 +16,6 @@ from .models import (
Company,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
@@ -34,9 +35,10 @@ class CompanyResource(InvenTreeResource):
@admin.register(Company)
class CompanyAdmin(ImportExportModelAdmin):
class CompanyAdmin(importer.admin.DataExportAdmin, ImportExportModelAdmin):
"""Admin class for the Company model."""
serializer_class = company.serializers.CompanySerializer
resource_class = CompanyResource
list_display = ('name', 'website', 'contact')
@@ -120,15 +122,6 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'manufacturer')
@admin.register(ManufacturerPartAttachment)
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPartAttachment model."""
list_display = ('manufacturer_part', 'attachment', 'comment')
autocomplete_fields = ('manufacturer_part',)
class ManufacturerPartParameterResource(InvenTreeResource):
"""Class for managing ManufacturerPartParameter data import/export."""

@@ -7,32 +7,25 @@ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
import part.models
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
ORDER_FILTER,
SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS,
)
from importer.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
from InvenTree.helpers import str2bool
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from .models import (
Address,
Company,
CompanyAttachment,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
)
from .serializers import (
AddressSerializer,
CompanyAttachmentSerializer,
CompanySerializer,
ContactSerializer,
ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer,
ManufacturerPartSerializer,
SupplierPartSerializer,
@@ -40,7 +33,7 @@ from .serializers import (
)
class CompanyList(ListCreateAPI):
class CompanyList(DataExportViewMixin, ListCreateAPI):
"""API endpoint for accessing a list of Company objects.
Provides two methods:
@@ -88,23 +81,7 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
return queryset
class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a CompanyAttachment."""
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
filterset_fields = ['company']
class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for CompanyAttachment model."""
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
class ContactList(ListCreateDestroyAPIView):
class ContactList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of Company model."""
queryset = Contact.objects.all()
@@ -128,7 +105,7 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
serializer_class = ContactSerializer
class AddressList(ListCreateDestroyAPIView):
class AddressList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of Address model."""
queryset = Address.objects.all()
@@ -169,7 +146,7 @@ class ManufacturerPartFilter(rest_filters.FilterSet):
)
class ManufacturerPartList(ListCreateDestroyAPIView):
class ManufacturerPartList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of ManufacturerPart object.
- GET: Return list of ManufacturerPart objects
@@ -227,22 +204,6 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
serializer_class = ManufacturerPartSerializer
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a ManufacturerPartAttachment (file upload)."""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
filterset_fields = ['manufacturer_part']
class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpooint for ManufacturerPartAttachment model."""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
class ManufacturerPartParameterFilter(rest_filters.FilterSet):
"""Custom filterset for the ManufacturerPartParameterList API endpoint."""
@@ -333,7 +294,7 @@ class SupplierPartFilter(rest_filters.FilterSet):
)
class SupplierPartList(ListCreateDestroyAPIView):
class SupplierPartList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of SupplierPart object.
- GET: Return list of SupplierPart objects
@@ -509,22 +470,6 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
manufacturer_part_api_urls = [
# Base URL for ManufacturerPartAttachment API endpoints
path(
'attachment/',
include([
path(
'<int:pk>/',
ManufacturerPartAttachmentDetail.as_view(),
name='api-manufacturer-part-attachment-detail',
),
path(
'',
ManufacturerPartAttachmentList.as_view(),
name='api-manufacturer-part-attachment-list',
),
]),
),
path(
'parameter/',
include([
@@ -611,19 +556,6 @@ company_api_urls = [
path('', CompanyDetail.as_view(), name='api-company-detail'),
]),
),
path(
'attachment/',
include([
path(
'<int:pk>/',
CompanyAttachmentDetail.as_view(),
name='api-company-attachment-detail',
),
path(
'', CompanyAttachmentList.as_view(), name='api-company-attachment-list'
),
]),
),
path(
'contact/',
include([

@@ -31,6 +31,9 @@ class Migration(migrations.Migration):
('is_customer', models.BooleanField(default=False, help_text='Do you sell items to this company?')),
('is_supplier', models.BooleanField(default=True, help_text='Do you purchase items from this company?')),
],
options={
'verbose_name': 'Company',
}
),
migrations.CreateModel(
name='Contact',
@@ -41,6 +44,9 @@ class Migration(migrations.Migration):
('email', models.EmailField(blank=True, max_length=254)),
('role', models.CharField(blank=True, max_length=100)),
],
options={
'verbose_name': 'Contact',
}
),
migrations.CreateModel(
name='SupplierPart',
@@ -60,6 +66,7 @@ class Migration(migrations.Migration):
],
options={
'db_table': 'part_supplierpart',
'verbose_name': 'Supplier Part',
},
),
migrations.CreateModel(
@@ -71,6 +78,7 @@ class Migration(migrations.Migration):
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pricebreaks', to='company.SupplierPart')),
],
options={
'verbose_name': 'Supplier Price Break',
'db_table': 'part_supplierpricebreak',
},
),

@@ -12,6 +12,6 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='company',
options={'ordering': ['name']},
options={'ordering': ['name'], 'verbose_name': 'Company'},
),
]

@@ -23,17 +23,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='company',
name='is_customer',
field=models.BooleanField(default=False, help_text='Do you sell items to this company?', verbose_name='is customer'),
field=models.BooleanField(default=False, help_text='Do you sell items to this company?', verbose_name='Is customer'),
),
migrations.AlterField(
model_name='company',
name='is_manufacturer',
field=models.BooleanField(default=False, help_text='Does this company manufacture parts?', verbose_name='is manufacturer'),
field=models.BooleanField(default=False, help_text='Does this company manufacture parts?', verbose_name='Is manufacturer'),
),
migrations.AlterField(
model_name='company',
name='is_supplier',
field=models.BooleanField(default=True, help_text='Do you purchase items from this company?', verbose_name='is supplier'),
field=models.BooleanField(default=True, help_text='Do you purchase items from this company?', verbose_name='Is supplier'),
),
migrations.AlterField(
model_name='company',

@@ -22,6 +22,7 @@ class Migration(migrations.Migration):
],
options={
'unique_together': {('part', 'manufacturer', 'MPN')},
'verbose_name': 'Manufacturer Part',
},
),
]

@@ -21,6 +21,7 @@ class Migration(migrations.Migration):
('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parameters', to='company.manufacturerpart', verbose_name='Manufacturer Part')),
],
options={
'verbose_name': 'Manufacturer Part Parameter',
'unique_together': {('manufacturer_part', 'name')},
},
),

@@ -12,6 +12,6 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='company',
options={'ordering': ['name'], 'verbose_name_plural': 'Companies'},
options={'ordering': ['name'], 'verbose_name': 'Company', 'verbose_name_plural': 'Companies'},
),
]

@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
name='ManufacturerPartAttachment',
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')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', 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')),

@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
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')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', 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')),

@@ -12,7 +12,10 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='address',
options={'verbose_name_plural': 'Addresses'},
options={
'verbose_name': 'Address',
'verbose_name_plural': 'Addresses'
},
),
migrations.AlterField(
model_name='address',

@@ -0,0 +1,24 @@
# Generated by Django 4.2.12 on 2024-06-09 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0026_auto_20240608_1238'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.DeleteModel(
name='CompanyAttachment',
),
migrations.DeleteModel(
name='ManufacturerPartAttachment',
),
]

@@ -0,0 +1,24 @@
# Generated by Django 4.2.11 on 2024-07-16 12:58
import InvenTree.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0070_remove_manufacturerpartattachment_manufacturer_part_and_more'),
]
operations = [
migrations.AddField(
model_name='manufacturerpart',
name='notes',
field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
),
migrations.AddField(
model_name='supplierpart',
name='notes',
field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
),
]

@@ -60,7 +60,9 @@ def rename_company_image(instance, filename):
class Company(
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeMetadataModel,
):
"""A Company object represents an external company.
@@ -95,7 +97,8 @@ class Company(
constraints = [
UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair')
]
verbose_name_plural = 'Companies'
verbose_name = _('Company')
verbose_name_plural = _('Companies')
@staticmethod
def get_api_url():
@@ -162,19 +165,19 @@ class Company(
is_customer = models.BooleanField(
default=False,
verbose_name=_('is customer'),
verbose_name=_('Is customer'),
help_text=_('Do you sell items to this company?'),
)
is_supplier = models.BooleanField(
default=True,
verbose_name=_('is supplier'),
verbose_name=_('Is supplier'),
help_text=_('Do you purchase items from this company?'),
)
is_manufacturer = models.BooleanField(
default=False,
verbose_name=_('is manufacturer'),
verbose_name=_('Is manufacturer'),
help_text=_('Does this company manufacture parts?'),
)
@@ -255,26 +258,6 @@ class Company(
).distinct()
class CompanyAttachment(InvenTree.models.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(InvenTree.models.InvenTreeMetadataModel):
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
@@ -286,6 +269,11 @@ class Contact(InvenTree.models.InvenTreeMetadataModel):
role: position in company
"""
class Meta:
"""Metaclass defines extra model options."""
verbose_name = _('Contact')
@staticmethod
def get_api_url():
"""Return the API URL associated with the Contcat model."""
@@ -323,7 +311,8 @@ class Address(InvenTree.models.InvenTreeModel):
class Meta:
"""Metaclass defines extra model options."""
verbose_name_plural = 'Addresses'
verbose_name = _('Address')
verbose_name_plural = _('Addresses')
def __init__(self, *args, **kwargs):
"""Custom init function."""
@@ -460,7 +449,10 @@ class Address(InvenTree.models.InvenTreeModel):
class ManufacturerPart(
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeMetadataModel,
):
"""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.
@@ -475,6 +467,7 @@ class ManufacturerPart(
class Meta:
"""Metaclass defines extra model options."""
verbose_name = _('Manufacturer Part')
unique_together = ('part', 'manufacturer', 'MPN')
@staticmethod
@@ -482,6 +475,11 @@ class ManufacturerPart(
"""Return the API URL associated with the ManufacturerPart instance."""
return reverse('api-manufacturer-part-list')
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'MP'
part = models.ForeignKey(
'part.Part',
on_delete=models.CASCADE,
@@ -563,26 +561,6 @@ class ManufacturerPart(
return s
class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a ManufacturerPart object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ManufacturerPartAttachment model."""
return reverse('api-manufacturer-part-attachment-list')
def getSubdir(self):
"""Return the subdirectory where attachment files for the ManufacturerPart model are located."""
return os.path.join('manufacturer_part_files', str(self.manufacturer_part.id))
manufacturer_part = models.ForeignKey(
ManufacturerPart,
on_delete=models.CASCADE,
verbose_name=_('Manufacturer Part'),
related_name='attachments',
)
class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
@@ -594,6 +572,7 @@ class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
class Meta:
"""Metaclass defines extra model options."""
verbose_name = _('Manufacturer Part Parameter')
unique_together = ('manufacturer_part', 'name')
@staticmethod
@@ -651,6 +630,7 @@ class SupplierPartManager(models.Manager):
class SupplierPart(
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
common.models.MetaMixin,
InvenTree.models.InvenTreeModel,
):
@@ -679,6 +659,8 @@ class SupplierPart(
unique_together = ('part', 'supplier', 'SKU')
verbose_name = _('Supplier Part')
# This model was moved from the 'Part' app
db_table = 'part_supplierpart'
@@ -701,6 +683,11 @@ class SupplierPart(
"""Return custom API filters for this particular instance."""
return {'manufacturer_part': {'part': self.part.pk}}
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'SP'
def clean(self):
"""Custom clean action for the SupplierPart model.
@@ -1037,6 +1024,7 @@ class SupplierPriceBreak(common.models.PriceBreak):
class Meta:
"""Metaclass defines extra model options."""
verbose_name = _('Supplier Price Break')
unique_together = ('part', 'quantity')
# This model was moved from the 'Part' app

@@ -10,8 +10,10 @@ from sql_util.utils import SubqueryCount
from taggit.serializers import TagListSerializerField
import part.filters
import part.serializers as part_serializers
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.serializers import (
InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
@@ -21,15 +23,12 @@ from InvenTree.serializers import (
NotesFieldMixin,
RemoteImageMixin,
)
from part.serializers import PartBriefSerializer
from .models import (
Address,
Company,
CompanyAttachment,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
@@ -59,7 +58,8 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
class AddressSerializer(InvenTreeModelSerializer):
@register_importer()
class AddressSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the Address Model."""
class Meta:
@@ -103,9 +103,19 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
]
class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSerializer):
@register_importer()
class CompanySerializer(
DataImportExportSerializerMixin,
NotesFieldMixin,
RemoteImageMixin,
InvenTreeModelSerializer,
):
"""Serializer for Company object (full detail)."""
export_exclude_fields = ['url', 'primary_address']
import_exclude_fields = ['image']
class Meta:
"""Metaclass options."""
@@ -186,28 +196,25 @@ class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSeriali
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):
@register_importer()
class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer class for the Contact model."""
class Meta:
"""Metaclass options."""
model = Contact
fields = ['pk', 'company', 'name', 'phone', 'email', 'role']
fields = ['pk', 'company', 'company_name', 'name', 'phone', 'email', 'role']
company_name = serializers.CharField(
label=_('Company Name'), source='company.name', read_only=True
)
class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
@register_importer()
class ManufacturerPartSerializer(
DataImportExportSerializerMixin, InvenTreeTagModelSerializer, NotesFieldMixin
):
"""Serializer for ManufacturerPart object."""
class Meta:
@@ -225,6 +232,7 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
'MPN',
'link',
'barcode_hash',
'notes',
'tags',
]
@@ -239,15 +247,17 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
self.fields.pop('part_detail', None)
if manufacturer_detail is not True:
self.fields.pop('manufacturer_detail')
self.fields.pop('manufacturer_detail', None)
if prettify is not True:
self.fields.pop('pretty_name')
self.fields.pop('pretty_name', None)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
part_detail = part_serializers.PartBriefSerializer(
source='part', many=False, read_only=True
)
manufacturer_detail = CompanyBriefSerializer(
source='manufacturer', many=False, read_only=True
@@ -260,18 +270,10 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
)
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the ManufacturerPartAttachment class."""
class Meta:
"""Metaclass options."""
model = ManufacturerPartAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields(['manufacturer_part'])
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
@register_importer()
class ManufacturerPartParameterSerializer(
DataImportExportSerializerMixin, InvenTreeModelSerializer
):
"""Serializer for the ManufacturerPartParameter model."""
class Meta:
@@ -295,14 +297,17 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
super().__init__(*args, **kwargs)
if not man_detail:
self.fields.pop('manufacturer_part_detail')
self.fields.pop('manufacturer_part_detail', None)
manufacturer_part_detail = ManufacturerPartSerializer(
source='manufacturer_part', many=False, read_only=True
)
class SupplierPartSerializer(InvenTreeTagModelSerializer):
@register_importer()
class SupplierPartSerializer(
DataImportExportSerializerMixin, InvenTreeTagModelSerializer, NotesFieldMixin
):
"""Serializer for SupplierPart object."""
class Meta:
@@ -336,6 +341,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
'supplier_detail',
'url',
'updated',
'notes',
'tags',
]
@@ -366,17 +372,22 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
self.fields.pop('part_detail', None)
if supplier_detail is not True:
self.fields.pop('supplier_detail')
self.fields.pop('supplier_detail', None)
if manufacturer_detail is not True:
self.fields.pop('manufacturer_detail')
self.fields.pop('manufacturer_part_detail')
self.fields.pop('manufacturer_detail', None)
self.fields.pop('manufacturer_part_detail', None)
if prettify is not True:
self.fields.pop('pretty_name')
if brief or prettify is not True:
self.fields.pop('pretty_name', None)
if brief:
self.fields.pop('tags')
self.fields.pop('available')
self.fields.pop('availability_updated')
# Annotated field showing total in-stock quantity
in_stock = serializers.FloatField(read_only=True, label=_('In Stock'))
@@ -385,7 +396,9 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
pack_quantity_native = serializers.FloatField(read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
part_detail = part_serializers.PartBriefSerializer(
source='part', many=False, read_only=True
)
supplier_detail = CompanyBriefSerializer(
source='supplier', many=False, read_only=True
@@ -460,7 +473,10 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
return supplier_part
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
@register_importer()
class SupplierPriceBreakSerializer(
DataImportExportSerializerMixin, InvenTreeModelSerializer
):
"""Serializer for SupplierPriceBreak object."""
class Meta:
@@ -487,10 +503,10 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
super().__init__(*args, **kwargs)
if not supplier_detail:
self.fields.pop('supplier_detail')
self.fields.pop('supplier_detail', None)
if not part_detail:
self.fields.pop('part_detail')
self.fields.pop('part_detail', None)
quantity = InvenTreeDecimalField()

@@ -244,17 +244,7 @@
{{ block.super }}
onPanelLoad("attachments", function() {
loadAttachmentTable('{% url "api-company-attachment-list" %}', {
filters: {
company: {{ company.pk }},
},
fields: {
company: {
value: {{ company.pk }},
hidden: true
}
}
});
loadAttachmentTable('company', {{ company.pk }});
});
// Callback function when the 'contacts' panel is loaded

@@ -171,23 +171,42 @@ src="{% static 'img/blank_image.png' %}"
</div>
</div>
<div class='panel panel-hidden' id='panel-manufacturer-part-notes'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Manufacturer Part Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
<textarea id='manufacturer-part-notes'></textarea>
</div>
</div>
{% endblock page_content %}
{% block js_ready %}
{{ block.super }}
onPanelLoad("attachments", function() {
loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', {
filters: {
manufacturer_part: {{ part.pk }},
},
fields: {
manufacturer_part: {
value: {{ part.pk }},
hidden: true
}
// Load the "notes" tab
onPanelLoad('manufacturer-part-notes', function() {
setupNotesField(
'manufacturer-part-notes',
'{% url "api-manufacturer-part-detail" part.pk %}',
{
model_type: "manufacturerpart",
model_id: {{ part.pk }},
editable: {% js_bool roles.purchase_order.change %},
}
});
);
});
onPanelLoad("attachments", function() {
loadAttachmentTable('manufacturerpart', {{ part.pk }});
});
$('#parameter-create').click(function() {

@@ -8,3 +8,5 @@
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label="manufacturer-part-notes" text=text icon="fa-clipboard" %}

@@ -264,17 +264,46 @@ src="{% static 'img/blank_image.png' %}"
</div>
</div>
<div class='panel panel-hidden' id='panel-supplier-part-notes'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Supplier Part Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
<textarea id='supplier-part-notes'></textarea>
</div>
</div>
{% endblock page_content %}
{% block js_ready %}
{{ block.super }}
// Load the "notes" tab
onPanelLoad('supplier-part-notes', function() {
setupNotesField(
'supplier-part-notes',
'{% url "api-supplier-part-detail" part.pk %}',
{
model_type: "supplierpart",
model_id: {{ part.pk }},
editable: {% js_bool roles.purchase_order.change %},
}
);
});
{% if barcodes %}
$("#show-qr-code").click(function() {
showQRDialog(
'{% trans "Supplier Part QR Code" escape %}',
'{"supplierpart": {{ part.pk }} }'
'{{ part.barcode }}'
);
});

@@ -8,3 +8,5 @@
{% include "sidebar_item.html" with label='purchase-orders' text=text icon="fa-shopping-cart" %}
{% trans "Supplier Part Pricing" as text %}
{% include "sidebar_item.html" with label='pricing' text=text icon="fa-dollar-sign" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label="supplier-part-notes" text=text icon="fa-clipboard" %}

@@ -57,22 +57,20 @@ class CompanyTest(InvenTreeAPITestCase):
def test_company_detail(self):
"""Tests for the Company detail endpoint."""
url = reverse('api-company-detail', kwargs={'pk': self.acme.pk})
response = self.get(url)
response = self.get(url, expected_code=200)
self.assertIn('name', response.data.keys())
self.assertEqual(response.data['name'], 'ACME')
# Change the name of the company
# Note we should not have the correct permissions (yet)
data = response.data
response = self.client.patch(url, data, format='json', expected_code=400)
self.assignRole('company.change')
# Update the name and set the currency to a valid value
data['name'] = 'ACMOO'
data['currency'] = 'NZD'
response = self.client.patch(url, data, format='json', expected_code=200)
response = self.patch(url, data, expected_code=200)
self.assertEqual(response.data['name'], 'ACMOO')
self.assertEqual(response.data['currency'], 'NZD')
@@ -162,7 +160,7 @@ class CompanyTest(InvenTreeAPITestCase):
class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models."""
roles = []
roles = ['purchase_order.view']
@classmethod
def setUpTestData(cls):
@@ -268,7 +266,7 @@ class ContactTest(InvenTreeAPITestCase):
class AddressTest(InvenTreeAPITestCase):
"""Test cases for Address API endpoints."""
roles = []
roles = ['purchase_order.view']
@classmethod
def setUpTestData(cls):

@@ -45,14 +45,7 @@ class TestManufacturerField(MigratorTestCase):
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
# Create an initial part
part = Part.objects.create(
name='Screw',
description='A single screw',
level=0,
tree_id=0,
lft=0,
rght=0,
)
part = Part.objects.create(name='Screw', description='A single screw')
# Create a company to act as the supplier
supplier = Company.objects.create(

@@ -11,6 +11,8 @@
# Note: Database configuration options can also be specified from environmental variables,
# with the prefix INVENTREE_DB_
# e.g INVENTREE_DB_NAME / INVENTREE_DB_USER / INVENTREE_DB_PASSWORD
# Do not change this section if you are using the package - use `inventree config` instead
# TO MAINTAINERS: Do not change database strings
database:
# --- Available options: ---
# ENGINE: Database engine. Selection from:
@@ -26,7 +28,7 @@ database:
# Set debug to False to run in production mode, or use the environment variable INVENTREE_DEBUG
debug: True
# Set to False to disable the admin interfac, or use the environment variable INVENTREE_ADMIN_ENABLED
# Set to False to disable the admin interface, or use the environment variable INVENTREE_ADMIN_ENABLED
#admin_enabled: True
# Set the admin URL, or use the environment variable INVENTREE_ADMIN_URL

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