2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-08 16:11:23 +00:00

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

This commit is contained in:
Matthias Mair
2024-06-25 07:54:58 +02:00
715 changed files with 247272 additions and 170212 deletions
+23 -18
View File
@@ -8,6 +8,7 @@ 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
@@ -73,8 +74,24 @@ class LicenseView(APIView):
logger.exception("Exception while reading license file '%s': %s", path, e)
return []
# Ensure consistent string between backend and frontend licenses
return [{key.lower(): value for key, value in entry.items()} for entry in data]
output = []
names = set()
# Ensure we do not have any duplicate 'name' values in the list
for entry in data:
name = None
for key in entry.keys():
if key.lower() == 'name':
name = entry[key]
break
if name is None or name in names:
continue
names.add(name)
output.append({key.lower(): value for key, value in entry.items()})
return output
@extend_schema(responses={200: OpenApiResponse(response=LicenseViewSerializer)})
def get(self, request, *args, **kwargs):
@@ -402,22 +419,6 @@ class APIDownloadMixin:
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."""
@@ -534,6 +535,10 @@ class MetadataView(RetrieveUpdateAPI):
"""Return the model type associated with this API instance."""
model = self.kwargs.get(self.MODEL_REF, None)
if 'lookup_field' in self.kwargs:
# Set custom lookup field (instead of default 'pk' value) if supplied
self.lookup_field = self.kwargs.pop('lookup_field')
if model is None:
raise ValidationError(
f"MetadataView called without '{self.MODEL_REF}' parameter"
+54 -1
View File
@@ -1,10 +1,63 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 192
INVENTREE_API_VERSION = 208
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
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
v205 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7284
- Added model_type and model_id fields to the "NotesImage" serializer
v204 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7393
- Fixes previous API update which resulted in inconsistent ordering of currency codes
v203 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7390
- Currency codes are now configurable as a run-time setting
v202 - 2024-05-27 : https://github.com/inventree/InvenTree/pull/7343
- Adjust "required" attribute of Part.category field to be optional
v201 - 2024-05-21 : https://github.com/inventree/InvenTree/pull/7074
- Major refactor of the report template / report printing interface
- This is a *breaking change* to the report template API
v200 - 2024-05-20 : https://github.com/inventree/InvenTree/pull/7000
- Adds API endpoint for generating custom batch codes
- Adds API endpoint for generating custom serial numbers
v199 - 2024-05-20 : https://github.com/inventree/InvenTree/pull/7264
- Expose "bom_valid" filter for the Part API
- Expose "starred" filter for the Part API
v198 - 2024-05-19 : https://github.com/inventree/InvenTree/pull/7258
- Fixed lookup field conflicts in the plugins API
v197 - 2024-05-14 : https://github.com/inventree/InvenTree/pull/7224
- Refactor the plugin API endpoints to use the plugin "key" for lookup, rather than the PK value
v196 - 2024-05-05 : https://github.com/inventree/InvenTree/pull/7160
- Adds "location" field to BuildOutputComplete API endpoint
v195 - 2024-05-03 : https://github.com/inventree/InvenTree/pull/7153
- Fixes bug in BuildOrderCancel API endpoint
v194 - 2024-05-01 : https://github.com/inventree/InvenTree/pull/7147
- Adds field description to the currency_exchange_retrieve API call
v193 - 2024-04-30 : https://github.com/inventree/InvenTree/pull/7144
- Adds "assigned_to" filter to PurchaseOrder / SalesOrder / ReturnOrder API endpoints
v192 - 2024-04-23 : https://github.com/inventree/InvenTree/pull/7106
- Adds 'trackable' ordering option to BuildLineLabel API endpoint
+13 -11
View File
@@ -14,6 +14,7 @@ from django.db.utils import IntegrityError, OperationalError
import InvenTree.conversion
import InvenTree.ready
import InvenTree.tasks
from common.settings import get_global_setting, set_global_setting
from InvenTree.config import get_setting
logger = logging.getLogger('inventree')
@@ -74,6 +75,7 @@ class InvenTreeConfig(AppConfig):
obsolete = [
'InvenTree.tasks.delete_expired_sessions',
'stock.tasks.delete_old_stock_items',
'label.tasks.cleanup_old_label_outputs',
]
try:
@@ -83,7 +85,14 @@ class InvenTreeConfig(AppConfig):
# Remove any existing obsolete tasks
try:
Schedule.objects.filter(func__in=obsolete).delete()
obsolete_tasks = Schedule.objects.filter(func__in=obsolete)
if obsolete_tasks.exists():
logger.info(
'Removing %s obsolete background tasks', obsolete_tasks.count()
)
obsolete_tasks.delete()
except Exception:
logger.exception('Failed to remove obsolete tasks - database not ready')
@@ -177,7 +186,7 @@ class InvenTreeConfig(AppConfig):
try:
from djmoney.contrib.exchange.models import ExchangeBackend
from common.settings import currency_code_default
from common.currency import currency_code_default
from InvenTree.tasks import update_exchange_rates
except AppRegistryNotReady: # pragma: no cover
pass
@@ -230,8 +239,6 @@ class InvenTreeConfig(AppConfig):
- If a fixed SITE_URL is specified (via configuration), it should override the INVENTREE_BASE_URL setting
- If multi-site support is enabled, update the site URL for the current site
"""
import common.models
if not InvenTree.ready.canAppAccessDatabase():
return
@@ -240,13 +247,8 @@ class InvenTreeConfig(AppConfig):
if settings.SITE_URL:
try:
if (
common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
!= settings.SITE_URL
):
common.models.InvenTreeSetting.set_setting(
'INVENTREE_BASE_URL', settings.SITE_URL
)
if get_global_setting('INVENTREE_BASE_URL') != settings.SITE_URL:
set_global_setting('INVENTREE_BASE_URL', settings.SITE_URL)
logger.info('Updated INVENTREE_SITE_URL to %s', settings.SITE_URL)
except Exception:
pass
+101
View File
@@ -0,0 +1,101 @@
"""Configuration options for InvenTree external cache."""
import logging
import socket
import InvenTree.config
import InvenTree.ready
logger = logging.getLogger('inventree')
def cache_setting(name, default=None, **kwargs):
"""Return a cache setting."""
return InvenTree.config.get_setting(
f'INVENTREE_CACHE_{name.upper()}', f'cache.{name.lower()}', default, **kwargs
)
def cache_host():
"""Return the cache host address."""
return cache_setting('host', None)
def cache_port():
"""Return the cache port."""
return cache_setting('port', '6379', typecast=int)
def is_global_cache_enabled():
"""Check if the global cache is enabled.
- Test if the user has enabled and configured global cache
- Test if it is appropriate to enable global cache based on the current operation.
"""
host = cache_host()
# Test if cache is enabled
# If the cache host is set, then the "default" action is to enable the cache
if not cache_setting('enabled', host is not None, typecast=bool):
return False
# Test if the cache is configured
if not cache_host():
logger.warning('Global cache is enabled, but no cache host is configured!')
return False
# The cache should not be used during certain operations
if not InvenTree.ready.canAppAccessDatabase(
allow_test=False, allow_plugins=False, allow_shell=True
):
logger.info('Global cache bypassed for this operation')
return False
logger.info('Global cache enabled')
return True
def get_cache_config(global_cache: bool) -> dict:
"""Return the cache configuration options.
Args:
global_cache: True if the global cache is enabled.
Returns:
A dictionary containing the cache configuration options.
"""
if global_cache:
return {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': f'redis://{cache_host()}:{cache_port()}/0',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'SOCKET_CONNECT_TIMEOUT': cache_setting(
'connect_timeout', 5, typecast=int
),
'SOCKET_TIMEOUT': cache_setting('timeout', 3, typecast=int),
'CONNECTION_POOL_KWARGS': {
'socket_keepalive': cache_setting(
'tcp_keepalive', True, typecast=bool
),
'socket_keepalive_options': {
socket.TCP_KEEPCNT: cache_setting(
'keepalive_count', 5, typecast=int
),
socket.TCP_KEEPIDLE: cache_setting(
'keepalive_idle', 1, typecast=int
),
socket.TCP_KEEPINTVL: cache_setting(
'keepalive_interval', 1, typecast=int
),
socket.TCP_USER_TIMEOUT: cache_setting(
'user_timeout', 1000, typecast=int
),
},
},
},
}
# Default: Use django local memory cache
return {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}
+3 -3
View File
@@ -7,7 +7,8 @@ from django.db.transaction import atomic
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from common.settings import currency_code_default, currency_codes
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)
+3 -6
View File
@@ -14,6 +14,7 @@ from rest_framework.fields import URLField as RestURLField
from rest_framework.fields import empty
import InvenTree.helpers
from common.settings import get_global_setting
from .validators import AllowedURLValidator, allowable_url_schemes
@@ -32,11 +33,7 @@ class InvenTreeRestURLField(RestURLField):
def run_validation(self, data=empty):
"""Override default validation behaviour for this field type."""
import common.models
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 and data is not empty and '://' not in data:
# Validate as if there were a schema provided
@@ -59,7 +56,7 @@ class InvenTreeURLField(models.URLField):
def money_kwargs(**kwargs):
"""Returns the database settings for MoneyFields."""
from common.settings import currency_code_default, currency_code_mappings
from common.currency import currency_code_default, currency_code_mappings
# Default values (if not specified)
if 'max_digits' not in kwargs:
@@ -166,3 +166,5 @@ SEARCH_ORDER_FILTER_ALIAS = [
]
ORDER_FILTER = [rest_filters.DjangoFilterBackend, filters.OrderingFilter]
ORDER_FILTER_ALIAS = [rest_filters.DjangoFilterBackend, InvenTreeOrderingFilter]
+10 -15
View File
@@ -20,7 +20,7 @@ from rest_framework import serializers
import InvenTree.helpers_model
import InvenTree.sso
from common.models import InvenTreeSetting
from common.settings import get_global_setting
from InvenTree.exceptions import log_error
logger = logging.getLogger('inventree')
@@ -168,12 +168,12 @@ class CustomSignupForm(SignupForm):
def __init__(self, *args, **kwargs):
"""Check settings to influence which fields are needed."""
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
kwargs['email_required'] = get_global_setting('LOGIN_MAIL_REQUIRED')
super().__init__(*args, **kwargs)
# check for two mail fields
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
if get_global_setting('LOGIN_SIGNUP_MAIL_TWICE'):
self.fields['email2'] = forms.EmailField(
label=_('Email (again)'),
widget=forms.TextInput(
@@ -185,7 +185,7 @@ class CustomSignupForm(SignupForm):
)
# check for two password fields
if not InvenTreeSetting.get_setting('LOGIN_SIGNUP_PWD_TWICE'):
if not get_global_setting('LOGIN_SIGNUP_PWD_TWICE'):
self.fields.pop('password2')
# reorder fields
@@ -198,7 +198,7 @@ class CustomSignupForm(SignupForm):
cleaned_data = super().clean()
# check for two mail fields
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
if get_global_setting('LOGIN_SIGNUP_MAIL_TWICE'):
email = cleaned_data.get('email')
email2 = cleaned_data.get('email2')
if (email and email2) and email != email2:
@@ -209,10 +209,7 @@ class CustomSignupForm(SignupForm):
def registration_enabled():
"""Determine whether user registration is enabled."""
if (
InvenTreeSetting.get_setting('LOGIN_ENABLE_REG')
or InvenTree.sso.registration_enabled()
):
if get_global_setting('LOGIN_ENABLE_REG') or InvenTree.sso.registration_enabled():
if settings.EMAIL_HOST:
return True
else:
@@ -236,9 +233,7 @@ class RegistratonMixin:
def clean_email(self, email):
"""Check if the mail is valid to the pattern in LOGIN_SIGNUP_MAIL_RESTRICTION (if enabled in settings)."""
mail_restriction = InvenTreeSetting.get_setting(
'LOGIN_SIGNUP_MAIL_RESTRICTION', None
)
mail_restriction = get_global_setting('LOGIN_SIGNUP_MAIL_RESTRICTION', None)
if not mail_restriction:
return super().clean_email(email)
@@ -269,7 +264,7 @@ class RegistratonMixin:
user = super().save_user(request, user, form)
# Check if a default group is set in settings
start_group = InvenTreeSetting.get_setting('SIGNUP_GROUP')
start_group = get_global_setting('SIGNUP_GROUP')
if start_group:
try:
group = Group.objects.get(id=start_group)
@@ -327,7 +322,7 @@ class CustomSocialAccountAdapter(
def is_auto_signup_allowed(self, request, sociallogin):
"""Check if auto signup is enabled in settings."""
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO', True):
if get_global_setting('LOGIN_SIGNUP_SSO_AUTO', True):
return super().is_auto_signup_allowed(request, sociallogin)
return False
@@ -356,7 +351,7 @@ class CustomRegisterSerializer(RegisterSerializer):
def __init__(self, instance=None, data=..., **kwargs):
"""Check settings to influence which fields are needed."""
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
kwargs['email_required'] = get_global_setting('LOGIN_MAIL_REQUIRED')
super().__init__(instance, data, **kwargs)
def save(self, request):
+3 -3
View File
@@ -28,7 +28,7 @@ from djmoney.money import Money
from PIL import Image
import InvenTree.version
from common.settings import currency_code_default
from common.currency import currency_code_default
from .settings import MEDIA_URL, STATIC_URL
@@ -293,13 +293,13 @@ def increment(value):
QQQ -> QQQ
"""
value = str(value).strip()
# Ignore empty strings
if value in ['', None]:
# Provide a default value if provided with a null input
return '1'
value = str(value).strip()
pattern = r'(.*?)(\d+)?$'
result = re.search(pattern, value)
@@ -15,7 +15,6 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from PIL import Image
import common.models
import InvenTree
import InvenTree.helpers_model
import InvenTree.version
@@ -24,16 +23,12 @@ from common.notifications import (
NotificationBody,
trigger_notification,
)
from common.settings import get_global_setting
from InvenTree.format import format_money
logger = logging.getLogger('inventree')
def getSetting(key, backup_value=None):
"""Shortcut for reading a setting value from the database."""
return common.models.InvenTreeSetting.get_setting(key, backup_value=backup_value)
def get_base_url(request=None):
"""Return the base URL for the InvenTree server.
@@ -44,6 +39,8 @@ 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('/')
@@ -62,9 +59,7 @@ def get_base_url(request=None):
# Check if a global InvenTree setting is provided
try:
if site_url := common.models.InvenTreeSetting.get_setting(
'INVENTREE_BASE_URL', create=False, cache=False
):
if site_url := get_global_setting('INVENTREE_BASE_URL', create=False):
return site_url
except (ProgrammingError, OperationalError):
pass
@@ -112,25 +107,20 @@ 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)
# Calculate maximum allowable image size (in bytes)
max_size = (
int(
common.models.InvenTreeSetting.get_setting(
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE'
)
)
* 1024
* 1024
int(get_global_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024
)
# Add user specified user-agent to request (if specified)
user_agent = common.models.InvenTreeSetting.get_setting(
'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT'
)
user_agent = get_global_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT')
if user_agent:
headers = {'User-Agent': user_agent}
else:
@@ -216,6 +206,8 @@ 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 '-'
@@ -231,19 +223,13 @@ def render_currency(
pass
if decimal_places is None:
decimal_places = common.models.InvenTreeSetting.get_setting(
'PRICING_DECIMAL_PLACES', 6
)
decimal_places = get_global_setting('PRICING_DECIMAL_PLACES', 6)
if min_decimal_places is None:
min_decimal_places = common.models.InvenTreeSetting.get_setting(
'PRICING_DECIMAL_PLACES_MIN', 0
)
min_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES_MIN', 0)
if max_decimal_places is None:
max_decimal_places = common.models.InvenTreeSetting.get_setting(
'PRICING_DECIMAL_PLACES', 6
)
max_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES', 6)
value = Decimal(str(money.amount)).normalize()
value = str(value)
@@ -38,6 +38,7 @@ LOCALES = [
('pl', _('Polish')),
('pt', _('Portuguese')),
('pt-br', _('Portuguese (Brazilian)')),
('ro', _('Romanian')),
('ru', _('Russian')),
('sk', _('Slovak')),
('sl', _('Slovenian')),
@@ -45,6 +46,7 @@ LOCALES = [
('sv', _('Swedish')),
('th', _('Thai')),
('tr', _('Turkish')),
('uk', _('Ukrainian')),
('vi', _('Vietnamese')),
('zh-hans', _('Chinese (Simplified)')),
('zh-hant', _('Chinese (Traditional)')),
@@ -0,0 +1,13 @@
"""Management command to collect plugin static files."""
from django.core.management import BaseCommand
class Command(BaseCommand):
"""Collect static files for all installed plugins."""
def handle(self, *args, **kwargs):
"""Run the management command."""
from plugin.staticfiles import collect_plugins_static_files
collect_plugins_static_files()
+60 -13
View File
@@ -115,16 +115,57 @@ class InvenTreeMetadata(SimpleMetadata):
return metadata
def override_value(self, field_name, field_value, model_value):
"""Override a value on the serializer with a matching value for the model.
This is used to override the serializer values with model values,
if (and *only* if) the model value should take precedence.
The values are overridden under the following conditions:
- 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)
"""
if model_value and not field_value:
return model_value
if field_value and not model_value:
return field_value
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:
return model_value
return field_value
def get_serializer_info(self, serializer):
"""Override get_serializer_info so that we can add 'default' values to any fields whose Meta.model specifies a default value."""
self.serializer = serializer
serializer_info = super().get_serializer_info(serializer)
# Look for any dynamic fields which were not available when the serializer was instantiated
if hasattr(serializer, 'Meta'):
for field_name in serializer.Meta.fields:
if field_name in serializer_info:
# Already know about this one
continue
if hasattr(serializer, field_name):
field = getattr(serializer, field_name)
serializer_info[field_name] = self.get_field_info(field)
model_class = None
# Attributes to copy extra attributes from the model to the field (if they don't exist)
extra_attributes = ['help_text', 'max_length']
# Note that the attributes may be named differently on the underlying model!
extra_attributes = {
'help_text': 'help_text',
'max_length': 'max_length',
'label': 'verbose_name',
}
try:
model_class = serializer.Meta.model
@@ -155,10 +196,12 @@ class InvenTreeMetadata(SimpleMetadata):
elif name in model_default_values:
serializer_info[name]['default'] = model_default_values[name]
for attr in extra_attributes:
if attr not in serializer_info[name]:
if hasattr(field, attr):
serializer_info[name][attr] = getattr(field, attr)
for field_key, model_key in extra_attributes.items():
field_value = serializer_info[name].get(field_key, None)
model_value = getattr(field, model_key, None)
if value := self.override_value(name, field_value, model_value):
serializer_info[name][field_key] = value
# Iterate through relations
for name, relation in model_fields.relations.items():
@@ -176,13 +219,12 @@ class InvenTreeMetadata(SimpleMetadata):
relation.model_field.get_limit_choices_to()
)
for attr in extra_attributes:
if attr not in serializer_info[name] and hasattr(
relation.model_field, attr
):
serializer_info[name][attr] = getattr(
relation.model_field, attr
)
for field_key, model_key in extra_attributes.items():
field_value = serializer_info[name].get(field_key, None)
model_value = getattr(relation.model_field, model_key, None)
if value := self.override_value(name, field_value, model_value):
serializer_info[name][field_key] = value
if name in model_default_values:
serializer_info[name]['default'] = model_default_values[name]
@@ -264,7 +306,9 @@ class InvenTreeMetadata(SimpleMetadata):
# Introspect writable related fields
if field_info['type'] == 'field' and not field_info['read_only']:
# If the field is a PrimaryKeyRelatedField, we can extract the model from the queryset
if isinstance(field, serializers.PrimaryKeyRelatedField):
if isinstance(field, serializers.PrimaryKeyRelatedField) or issubclass(
field.__class__, serializers.PrimaryKeyRelatedField
):
model = field.queryset.model
else:
logger.debug(
@@ -285,6 +329,9 @@ class InvenTreeMetadata(SimpleMetadata):
else:
field_info['api_url'] = model.get_api_url()
# Handle custom 'primary key' field
field_info['pk_field'] = getattr(field, 'pk_field', 'pk') or 'pk'
# Add more metadata about dependent fields
if field_info['type'] == 'dependent field':
field_info['depends_on'] = field.depends_on
@@ -11,6 +11,7 @@ from django.urls import include, path, resolve, reverse_lazy
from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting
from InvenTree.urls import frontendpatterns
from users.models import ApiToken
@@ -69,7 +70,8 @@ class AuthRequiredMiddleware(object):
# API requests are handled by the DRF library
if request.path_info.startswith('/api/'):
return self.get_response(request)
response = self.get_response(request)
return response
# Is the function exempt from auth requirements?
path_func = resolve(request.path).func
@@ -143,9 +145,6 @@ class AuthRequiredMiddleware(object):
return response
url_matcher = path('', include(frontendpatterns))
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
"""Middleware to check if HTTP-header based auth is enabled and to set it up."""
+80 -183
View File
@@ -1,9 +1,7 @@
"""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
@@ -20,11 +18,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')
@@ -122,6 +120,20 @@ class PluginValidationMixin(DiffMixin):
self.run_plugin_validation()
super().save(*args, **kwargs)
def delete(self):
"""Run plugin validation on model delete.
Allows plugins to prevent model instances from being deleted.
Note: Each plugin may raise a ValidationError to prevent deletion.
"""
from plugin.registry import registry
for plugin in registry.with_mixin('validation'):
plugin.validate_model_deletion(self)
super().delete()
class MetadataMixin(models.Model):
"""Model mixin class which adds a JSON metadata field to a model, for use by any (and all) plugins.
@@ -290,10 +302,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()
@@ -489,200 +498,64 @@ 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):
@@ -1017,6 +890,30 @@ class InvenTreeNotesMixin(models.Model):
abstract = True
def delete(self):
"""Custom delete method for InvenTreeNotesMixin.
- Before deleting the object, check if there are any uploaded images associated with it.
- If so, delete the notes first
"""
from common.models import NotesImage
images = NotesImage.objects.filter(
model_type=self.__class__.__name__.lower(), model_id=self.pk
)
if images.exists():
logger.info(
'Deleting %s uploaded images associated with %s <%s>',
images.count(),
self.__class__.__name__,
self.pk,
)
images.delete()
super().delete()
notes = InvenTree.fields.InvenTreeNotesField(
verbose_name=_('Notes'), help_text=_('Markdown notes (optional)')
)
+2 -1
View File
@@ -114,6 +114,7 @@ def canAppAccessDatabase(
'wait_for_db',
'makemessages',
'compilemessages',
'spectactular',
]
if not allow_shell:
@@ -124,7 +125,7 @@ def canAppAccessDatabase(
excluded_commands.append('test')
if not allow_plugins:
excluded_commands.extend(['collectstatic'])
excluded_commands.extend(['collectstatic', 'collectplugins'])
for cmd in excluded_commands:
if cmd in sys.argv:
+19 -38
View File
@@ -18,12 +18,13 @@ from djmoney.utils import MONEY_CLASSES, get_currency_field_name
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import empty
from rest_framework.mixins import ListModelMixin
from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer
import common.models as common_models
from common.settings import currency_code_default, currency_code_mappings
from common.currency import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
@@ -508,43 +509,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.
@@ -842,6 +806,23 @@ class DataFileExtractSerializer(serializers.Serializer):
pass
class NotesFieldMixin:
"""Serializer mixin for handling 'notes' fields.
The 'notes' field will be hidden in a LIST serializer,
but available in a DETAIL serializer.
"""
def __init__(self, *args, **kwargs):
"""Remove 'notes' field from list views."""
super().__init__(*args, **kwargs)
if hasattr(self, 'context'):
if view := self.context.get('view', None):
if issubclass(view.__class__, ListModelMixin):
self.fields.pop('notes', None)
class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
"""Mixin class which allows downloading an 'image' from a remote URL.
+46 -77
View File
@@ -11,7 +11,6 @@ database setup in this file.
import logging
import os
import socket
import sys
from pathlib import Path
@@ -21,10 +20,10 @@ from django.core.validators import URLValidator
from django.http import Http404
from django.utils.translation import gettext_lazy as _
import moneyed
import pytz
from dotenv import load_dotenv
from InvenTree.cache import get_cache_config, is_global_cache_enabled
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
from InvenTree.ready import isInMainThread
from InvenTree.sentry import default_sentry_dsn, init_sentry
@@ -193,7 +192,6 @@ INSTALLED_APPS = [
'common.apps.CommonConfig',
'company.apps.CompanyConfig',
'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function
'label.apps.LabelConfig',
'order.apps.OrderConfig',
'part.apps.PartConfig',
'report.apps.ReportConfig',
@@ -271,17 +269,18 @@ if DEBUG and get_boolean_setting(
'INVENTREE_DEBUG_QUERYCOUNT', 'debug_querycount', False
):
MIDDLEWARE.append('querycount.middleware.QueryCountMiddleware')
logger.debug('Running with debug_querycount middleware enabled')
QUERYCOUNT = {
'THRESHOLDS': {
'MEDIUM': 50,
'HIGH': 200,
'MIN_TIME_TO_LOG': 0,
'MIN_QUERY_COUNT_TO_LOG': 0,
'MIN_TIME_TO_LOG': 0.1,
'MIN_QUERY_COUNT_TO_LOG': 25,
},
'IGNORE_REQUEST_PATTERNS': ['^(?!\/(api)?(plugin)?\/).*'],
'IGNORE_SQL_PATTERNS': [],
'DISPLAY_DUPLICATES': 3,
'DISPLAY_DUPLICATES': 1,
'RESPONSE_HEADER': 'X-Django-Query-Count',
}
@@ -294,6 +293,7 @@ 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:
@@ -431,12 +431,7 @@ ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
BASE_DIR.joinpath('templates'),
# Allow templates in the reporting directory to be accessed
MEDIA_ROOT.joinpath('report'),
MEDIA_ROOT.joinpath('label'),
],
'DIRS': [BASE_DIR.joinpath('templates'), MEDIA_ROOT.joinpath('report')],
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
@@ -806,38 +801,9 @@ if TRACING_ENABLED: # pragma: no cover
# endregion
# Cache configuration
cache_host = get_setting('INVENTREE_CACHE_HOST', 'cache.host', None)
cache_port = get_setting('INVENTREE_CACHE_PORT', 'cache.port', '6379', typecast=int)
GLOBAL_CACHE_ENABLED = is_global_cache_enabled()
if cache_host: # pragma: no cover
# We are going to rely upon a possibly non-localhost for our cache,
# so don't wait too long for the cache as nothing in the cache should be
# irreplaceable.
_cache_options = {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'SOCKET_CONNECT_TIMEOUT': int(os.getenv('CACHE_CONNECT_TIMEOUT', '2')),
'SOCKET_TIMEOUT': int(os.getenv('CACHE_SOCKET_TIMEOUT', '2')),
'CONNECTION_POOL_KWARGS': {
'socket_keepalive': config.is_true(os.getenv('CACHE_TCP_KEEPALIVE', '1')),
'socket_keepalive_options': {
socket.TCP_KEEPCNT: int(os.getenv('CACHE_KEEPALIVES_COUNT', '5')),
socket.TCP_KEEPIDLE: int(os.getenv('CACHE_KEEPALIVES_IDLE', '1')),
socket.TCP_KEEPINTVL: int(os.getenv('CACHE_KEEPALIVES_INTERVAL', '1')),
socket.TCP_USER_TIMEOUT: int(
os.getenv('CACHE_TCP_USER_TIMEOUT', '1000')
),
},
},
}
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': f'redis://{cache_host}:{cache_port}/0',
'OPTIONS': _cache_options,
}
}
else:
CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
CACHES = {'default': get_cache_config(GLOBAL_CACHE_ENABLED)}
_q_worker_timeout = int(
get_setting('INVENTREE_BACKGROUND_TIMEOUT', 'background.timeout', 90)
@@ -868,7 +834,7 @@ Q_CLUSTER = {
if SENTRY_ENABLED and SENTRY_DSN:
Q_CLUSTER['error_reporter'] = {'sentry': {'dsn': SENTRY_DSN}}
if cache_host: # pragma: no cover
if GLOBAL_CACHE_ENABLED: # pragma: no cover
# If using external redis cache, make the cache the broker for Django Q
# as well
Q_CLUSTER['django_redis'] = 'worker'
@@ -935,28 +901,9 @@ if get_boolean_setting('TEST_TRANSLATIONS', default_value=False): # pragma: no
LANG_INFO = dict(django.conf.locale.LANG_INFO, **EXTRA_LANG_INFO)
django.conf.locale.LANG_INFO = LANG_INFO
# Currencies available for use
CURRENCIES = get_setting(
'INVENTREE_CURRENCIES',
'currencies',
['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD'],
typecast=list,
)
# Ensure that at least one currency value is available
if len(CURRENCIES) == 0: # pragma: no cover
logger.warning('No currencies selected: Defaulting to USD')
CURRENCIES = ['USD']
# Maximum number of decimal places for currency rendering
CURRENCY_DECIMAL_PLACES = 6
# Check that each provided currency is supported
for currency in CURRENCIES:
if currency not in moneyed.CURRENCIES: # pragma: no cover
logger.error("Currency code '%s' is not supported", currency)
sys.exit(1)
# Custom currency exchange backend
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange'
@@ -1019,8 +966,12 @@ if SITE_URL:
logger.info('Using Site URL: %s', SITE_URL)
# Check that the site URL is valid
validator = URLValidator()
validator(SITE_URL)
try:
validator = URLValidator()
validator(SITE_URL)
except Exception:
print(f"Invalid SITE_URL value: '{SITE_URL}'. InvenTree server cannot start.")
sys.exit(-1)
# Enable or disable multi-site framework
SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
@@ -1087,26 +1038,44 @@ if SITE_URL and SITE_URL not in CSRF_TRUSTED_ORIGINS:
if DEBUG:
for origin in [
'http://localhost',
'http://*.localhost' 'http://*localhost:8000',
'http://*.localhost',
'http://*localhost:8000',
'http://*localhost:5173',
]:
if origin not in CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS.append(origin)
if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
if isInMainThread():
# Server thread cannot run without CSRF_TRUSTED_ORIGINS
logger.error(
'No CSRF_TRUSTED_ORIGINS specified. Please provide a list of trusted origins, or specify INVENTREE_SITE_URL'
)
sys.exit(-1)
if (
not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0 and isInMainThread()
): # pragma: no cover
# Server thread cannot run without CSRF_TRUSTED_ORIGINS
logger.error(
'No CSRF_TRUSTED_ORIGINS specified. Please provide a list of trusted origins, or specify INVENTREE_SITE_URL'
)
sys.exit(-1)
COOKIE_MODE = (
str(get_setting('INVENTREE_COOKIE_SAMESITE', 'cookie.samesite', 'None'))
.lower()
.strip()
)
valid_cookie_modes = {'lax': 'Lax', 'strict': 'Strict', 'none': None, 'null': None}
if COOKIE_MODE not in valid_cookie_modes.keys():
logger.error('Invalid cookie samesite mode: %s', COOKIE_MODE)
sys.exit(-1)
COOKIE_MODE = valid_cookie_modes[COOKIE_MODE.lower()]
# Additional CSRF settings
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = COOKIE_MODE
SESSION_COOKIE_SAMESITE = COOKIE_MODE
SESSION_COOKIE_SECURE = get_boolean_setting(
'INVENTREE_SESSION_COOKIE_SECURE', 'cookie.secure', False
)
USE_X_FORWARDED_HOST = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_HOST',
@@ -1301,7 +1270,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?
@@ -15,7 +15,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
import InvenTree.sso
from common.models import InvenTreeSetting
from common.settings import get_global_setting
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer
@@ -177,12 +177,10 @@ class SocialProviderListView(ListAPI):
data = {
'sso_enabled': InvenTree.sso.login_enabled(),
'sso_registration': InvenTree.sso.registration_enabled(),
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
'mfa_required': get_global_setting('LOGIN_ENFORCE_MFA'),
'providers': provider_list,
'registration_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_REG'),
'password_forgotten_enabled': InvenTreeSetting.get_setting(
'LOGIN_ENABLE_PWD_FORGOT'
),
'registration_enabled': get_global_setting('LOGIN_ENABLE_REG'),
'password_forgotten_enabled': get_global_setting('LOGIN_ENABLE_PWD_FORGOT'),
}
return Response(data)
+4 -4
View File
@@ -2,7 +2,7 @@
import logging
from common.models import InvenTreeSetting
from common.settings import get_global_setting
from InvenTree.helpers import str2bool
logger = logging.getLogger('inventree')
@@ -64,14 +64,14 @@ def provider_display_name(provider):
def login_enabled() -> bool:
"""Return True if SSO login is enabled."""
return str2bool(InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO'))
return str2bool(get_global_setting('LOGIN_ENABLE_SSO'))
def registration_enabled() -> bool:
"""Return True if SSO registration is enabled."""
return str2bool(InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'))
return str2bool(get_global_setting('LOGIN_ENABLE_SSO_REG'))
def auto_registration_enabled() -> bool:
"""Return True if SSO auto-registration is enabled."""
return str2bool(InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO'))
return str2bool(get_global_setting('LOGIN_SIGNUP_SSO_AUTO'))
+7 -195
View File
@@ -1,197 +1,9 @@
"""Status codes for InvenTree."""
"""Global import of all status codes.
from django.utils.translation import gettext_lazy as _
This file remains here for backwards compatibility,
as external plugins may import status codes from this file.
"""
from generic.states import StatusCode
class PurchaseOrderStatus(StatusCode):
"""Defines a set of status codes for a PurchaseOrder."""
# Order status codes
PENDING = 10, _('Pending'), 'secondary' # Order is pending (not yet placed)
PLACED = 20, _('Placed'), 'primary' # Order has been placed with supplier
COMPLETE = 30, _('Complete'), 'success' # Order has been completed
CANCELLED = 40, _('Cancelled'), 'danger' # Order was cancelled
LOST = 50, _('Lost'), 'warning' # Order was lost
RETURNED = 60, _('Returned'), 'warning' # Order was returned
class PurchaseOrderStatusGroups:
"""Groups for PurchaseOrderStatus codes."""
# Open orders
OPEN = [PurchaseOrderStatus.PENDING.value, PurchaseOrderStatus.PLACED.value]
# Failed orders
FAILED = [
PurchaseOrderStatus.CANCELLED.value,
PurchaseOrderStatus.LOST.value,
PurchaseOrderStatus.RETURNED.value,
]
class SalesOrderStatus(StatusCode):
"""Defines a set of status codes for a SalesOrder."""
PENDING = 10, _('Pending'), 'secondary' # Order is pending
IN_PROGRESS = (
15,
_('In Progress'),
'primary',
) # Order has been issued, and is in progress
SHIPPED = 20, _('Shipped'), 'success' # Order has been shipped to customer
CANCELLED = 40, _('Cancelled'), 'danger' # Order has been cancelled
LOST = 50, _('Lost'), 'warning' # Order was lost
RETURNED = 60, _('Returned'), 'warning' # Order was returned
class SalesOrderStatusGroups:
"""Groups for SalesOrderStatus codes."""
# Open orders
OPEN = [SalesOrderStatus.PENDING.value, SalesOrderStatus.IN_PROGRESS.value]
# Completed orders
COMPLETE = [SalesOrderStatus.SHIPPED.value]
class StockStatus(StatusCode):
"""Status codes for Stock."""
OK = 10, _('OK'), 'success' # Item is OK
ATTENTION = 50, _('Attention needed'), 'warning' # Item requires attention
DAMAGED = 55, _('Damaged'), 'warning' # Item is damaged
DESTROYED = 60, _('Destroyed'), 'danger' # Item is destroyed
REJECTED = 65, _('Rejected'), 'danger' # Item is rejected
LOST = 70, _('Lost'), 'dark' # Item has been lost
QUARANTINED = (
75,
_('Quarantined'),
'info',
) # Item has been quarantined and is unavailable
RETURNED = 85, _('Returned'), 'warning' # Item has been returned from a customer
class StockStatusGroups:
"""Groups for StockStatus codes."""
# The following codes correspond to parts that are 'available' or 'in stock'
AVAILABLE_CODES = [
StockStatus.OK.value,
StockStatus.ATTENTION.value,
StockStatus.DAMAGED.value,
StockStatus.RETURNED.value,
]
class StockHistoryCode(StatusCode):
"""Status codes for StockHistory."""
LEGACY = 0, _('Legacy stock tracking entry')
CREATED = 1, _('Stock item created')
# Manual editing operations
EDITED = 5, _('Edited stock item')
ASSIGNED_SERIAL = 6, _('Assigned serial number')
# Manual stock operations
STOCK_COUNT = 10, _('Stock counted')
STOCK_ADD = 11, _('Stock manually added')
STOCK_REMOVE = 12, _('Stock manually removed')
# Location operations
STOCK_MOVE = 20, _('Location changed')
STOCK_UPDATE = 25, _('Stock updated')
# Installation operations
INSTALLED_INTO_ASSEMBLY = 30, _('Installed into assembly')
REMOVED_FROM_ASSEMBLY = 31, _('Removed from assembly')
INSTALLED_CHILD_ITEM = 35, _('Installed component item')
REMOVED_CHILD_ITEM = 36, _('Removed component item')
# Stock splitting operations
SPLIT_FROM_PARENT = 40, _('Split from parent item')
SPLIT_CHILD_ITEM = 42, _('Split child item')
# Stock merging operations
MERGED_STOCK_ITEMS = 45, _('Merged stock items')
# Convert stock item to variant
CONVERTED_TO_VARIANT = 48, _('Converted to variant')
# Build order codes
BUILD_OUTPUT_CREATED = 50, _('Build order output created')
BUILD_OUTPUT_COMPLETED = 55, _('Build order output completed')
BUILD_OUTPUT_REJECTED = 56, _('Build order output rejected')
BUILD_CONSUMED = 57, _('Consumed by build order')
# Sales order codes
SHIPPED_AGAINST_SALES_ORDER = 60, _('Shipped against Sales Order')
# Purchase order codes
RECEIVED_AGAINST_PURCHASE_ORDER = 70, _('Received against Purchase Order')
# Return order codes
RETURNED_AGAINST_RETURN_ORDER = 80, _('Returned against Return Order')
# Customer actions
SENT_TO_CUSTOMER = 100, _('Sent to customer')
RETURNED_FROM_CUSTOMER = 105, _('Returned from customer')
class BuildStatus(StatusCode):
"""Build status codes."""
PENDING = 10, _('Pending'), 'secondary' # Build is pending / active
PRODUCTION = 20, _('Production'), 'primary' # BuildOrder is in production
CANCELLED = 30, _('Cancelled'), 'danger' # Build was cancelled
COMPLETE = 40, _('Complete'), 'success' # Build is complete
class BuildStatusGroups:
"""Groups for BuildStatus codes."""
ACTIVE_CODES = [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value]
class ReturnOrderStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrder."""
# Order is pending, waiting for receipt of items
PENDING = 10, _('Pending'), 'secondary'
# Items have been received, and are being inspected
IN_PROGRESS = 20, _('In Progress'), 'primary'
COMPLETE = 30, _('Complete'), 'success'
CANCELLED = 40, _('Cancelled'), 'danger'
class ReturnOrderStatusGroups:
"""Groups for ReturnOrderStatus codes."""
OPEN = [ReturnOrderStatus.PENDING.value, ReturnOrderStatus.IN_PROGRESS.value]
class ReturnOrderLineStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrderLineItem."""
PENDING = 10, _('Pending'), 'secondary'
# Item is to be returned to customer, no other action
RETURN = 20, _('Return'), 'success'
# Item is to be repaired, and returned to customer
REPAIR = 30, _('Repair'), 'primary'
# Item is to be replaced (new item shipped)
REPLACE = 40, _('Replace'), 'warning'
# Item is to be refunded (cannot be repaired)
REFUND = 50, _('Refund'), 'info'
# Item is rejected
REJECT = 60, _('Reject'), 'danger'
from build.status_codes import *
from order.status_codes import *
from stock.status_codes import *
+17 -46
View File
@@ -26,6 +26,7 @@ from maintenance_mode.core import (
set_maintenance_mode,
)
from common.settings import get_global_setting, set_global_setting
from InvenTree.config import get_setting
from plugin import registry
@@ -90,7 +91,6 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
Note that this function creates some *hidden* global settings (designated with the _ prefix),
which are used to keep a running track of when the particular task was was last run.
"""
from common.models import InvenTreeSetting
from InvenTree.ready import isInTestMode
if n_days <= 0:
@@ -107,7 +107,7 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
success_key = f'_{task_name}_SUCCESS'
# Check for recent success information
last_success = InvenTreeSetting.get_setting(success_key, '', cache=False)
last_success = get_global_setting(success_key, '', cache=False)
if last_success:
try:
@@ -125,7 +125,7 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
return False
# Check for any information we have about this task
last_attempt = InvenTreeSetting.get_setting(attempt_key, '', cache=False)
last_attempt = get_global_setting(attempt_key, '', cache=False)
if last_attempt:
try:
@@ -152,22 +152,14 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
def record_task_attempt(task_name: str):
"""Record that a multi-day task has been attempted *now*."""
from common.models import InvenTreeSetting
logger.info("Logging task attempt for '%s'", task_name)
InvenTreeSetting.set_setting(
f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None
)
set_global_setting(f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None)
def record_task_success(task_name: str):
"""Record that a multi-day task was successful *now*."""
from common.models import InvenTreeSetting
InvenTreeSetting.set_setting(
f'_{task_name}_SUCCESS', datetime.now().isoformat(), None
)
set_global_setting(f'_{task_name}_SUCCESS', datetime.now().isoformat(), None)
def offload_task(
@@ -380,9 +372,7 @@ def delete_successful_tasks():
try:
from django_q.models import Success
from common.models import InvenTreeSetting
days = InvenTreeSetting.get_setting('INVENTREE_DELETE_TASKS_DAYS', 30)
days = get_global_setting('INVENTREE_DELETE_TASKS_DAYS', 30)
threshold = timezone.now() - timedelta(days=days)
# Delete successful tasks
@@ -404,9 +394,7 @@ def delete_failed_tasks():
try:
from django_q.models import Failure
from common.models import InvenTreeSetting
days = InvenTreeSetting.get_setting('INVENTREE_DELETE_TASKS_DAYS', 30)
days = get_global_setting('INVENTREE_DELETE_TASKS_DAYS', 30)
threshold = timezone.now() - timedelta(days=days)
# Delete failed tasks
@@ -426,9 +414,7 @@ def delete_old_error_logs():
try:
from error_report.models import Error
from common.models import InvenTreeSetting
days = InvenTreeSetting.get_setting('INVENTREE_DELETE_ERRORS_DAYS', 30)
days = get_global_setting('INVENTREE_DELETE_ERRORS_DAYS', 30)
threshold = timezone.now() - timedelta(days=days)
errors = Error.objects.filter(when__lte=threshold)
@@ -448,13 +434,9 @@ def delete_old_error_logs():
def delete_old_notifications():
"""Delete old notification logs."""
try:
from common.models import (
InvenTreeSetting,
NotificationEntry,
NotificationMessage,
)
from common.models import NotificationEntry, NotificationMessage
days = InvenTreeSetting.get_setting('INVENTREE_DELETE_NOTIFICATIONS_DAYS', 30)
days = get_global_setting('INVENTREE_DELETE_NOTIFICATIONS_DAYS', 30)
threshold = timezone.now() - timedelta(days=days)
items = NotificationEntry.objects.filter(updated__lte=threshold)
@@ -479,7 +461,6 @@ def delete_old_notifications():
def check_for_updates():
"""Check if there is an update for InvenTree."""
try:
import common.models
from common.notifications import trigger_superuser_notification
except AppRegistryNotReady: # pragma: no cover
# Apps not yet loaded!
@@ -487,9 +468,7 @@ def check_for_updates():
return
interval = int(
common.models.InvenTreeSetting.get_setting(
'INVENTREE_UPDATE_CHECK_INTERVAL', 7, cache=False
)
get_global_setting('INVENTREE_UPDATE_CHECK_INTERVAL', 7, cache=False)
)
# Check if we should check for updates *today*
@@ -538,7 +517,7 @@ def check_for_updates():
logger.info("Latest InvenTree version: '%s'", tag)
# Save the version to the database
common.models.InvenTreeSetting.set_setting('_INVENTREE_LATEST_VERSION', tag, None)
set_global_setting('_INVENTREE_LATEST_VERSION', tag, None)
# Record that this task was successful
record_task_success('check_for_updates')
@@ -571,8 +550,7 @@ def update_exchange_rates(force: bool = False):
try:
from djmoney.contrib.exchange.models import Rate
from common.models import InvenTreeSetting
from common.settings import currency_code_default, currency_codes
from common.currency import currency_code_default, currency_codes
from InvenTree.exchange import InvenTreeExchange
except AppRegistryNotReady: # pragma: no cover
# Apps not yet loaded!
@@ -585,9 +563,7 @@ def update_exchange_rates(force: bool = False):
return
if not force:
interval = int(
InvenTreeSetting.get_setting('CURRENCY_UPDATE_INTERVAL', 1, cache=False)
)
interval = int(get_global_setting('CURRENCY_UPDATE_INTERVAL', 1, cache=False))
if not check_daily_holdoff('update_exchange_rates', interval):
logger.info('Skipping exchange rate update (interval not reached)')
@@ -617,15 +593,11 @@ def update_exchange_rates(force: bool = False):
@scheduled_task(ScheduledTask.DAILY)
def run_backup():
"""Run the backup command."""
from common.models import InvenTreeSetting
if not InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE', False, cache=False):
if not get_global_setting('INVENTREE_BACKUP_ENABLE', False, cache=False):
# Backups are not enabled - exit early
return
interval = int(
InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False)
)
interval = int(get_global_setting('INVENTREE_BACKUP_DAYS', 1, cache=False))
# Check if should run this task *today*
if not check_daily_holdoff('run_backup', interval):
@@ -655,13 +627,12 @@ def check_for_migrations(force: bool = False, reload_registry: bool = True):
If the setting auto_update is enabled we will start updating.
"""
from common.models import InvenTreeSetting
from plugin import registry
def set_pending_migrations(n: int):
"""Helper function to inform the user about pending migrations."""
logger.info('There are %s pending migrations', n)
InvenTreeSetting.set_setting('_PENDING_MIGRATIONS', n, None)
set_global_setting('_PENDING_MIGRATIONS', n, None)
logger.info('Checking for pending database migrations')
@@ -16,7 +16,8 @@ import common.models
import InvenTree.helpers
import InvenTree.helpers_model
import plugin.models
from common.settings import currency_code_default
from common.currency import currency_code_default
from common.settings import get_global_setting
from InvenTree import settings, version
from plugin import registry
from plugin.plugin import InvenTreePlugin
@@ -135,7 +136,7 @@ def inventree_in_debug_mode(*args, **kwargs):
@register.simple_tag()
def inventree_show_about(user, *args, **kwargs):
"""Return True if the about modal should be shown."""
if common.models.InvenTreeSetting.get_setting('INVENTREE_RESTRICT_ABOUT'):
if get_global_setting('INVENTREE_RESTRICT_ABOUT'):
# Return False if the user is not a superuser, or no user information is provided
if not user or not user.is_superuser:
return False
@@ -373,7 +374,7 @@ def settings_value(key, *args, **kwargs):
return common.models.InvenTreeUserSetting.get_setting(key)
return common.models.InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
return common.models.InvenTreeSetting.get_setting(key)
return get_global_setting(key)
@register.simple_tag()
+1 -1
View File
@@ -128,7 +128,7 @@ class APITests(InvenTreeAPITestCase):
response = self.client.get(url, format='json')
# Not logged in, so cannot access user role data
self.assertTrue(response.status_code in [401, 403])
self.assertIn(response.status_code, [401, 403])
# Now log in!
self.basicAuth()
@@ -122,6 +122,7 @@ class InvenTreeTaskTests(TestCase):
def test_task_check_for_updates(self):
"""Test the task check_for_updates."""
# Check that setting should be empty
InvenTreeSetting.set_setting('_INVENTREE_LATEST_VERSION', '')
self.assertEqual(InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION'), '')
# Get new version
+9 -8
View File
@@ -29,8 +29,8 @@ import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.tasks
from common.currency import currency_codes
from common.models import CustomUnit, InvenTreeSetting
from common.settings import currency_codes
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
from InvenTree.sanitizer import sanitize_svg
from InvenTree.unit_test import InvenTreeTestCase
@@ -1030,7 +1030,7 @@ class TestVersionNumber(TestCase):
s = '.'.join([str(i) for i in v])
self.assertTrue(s in version.inventreeVersion())
self.assertIn(s, version.inventreeVersion())
def test_comparison(self):
"""Test direct comparison of version numbers."""
@@ -1039,10 +1039,10 @@ class TestVersionNumber(TestCase):
v_c = version.inventreeVersionTuple('1.2.4')
v_d = version.inventreeVersionTuple('2.0.0')
self.assertTrue(v_b > v_a)
self.assertTrue(v_c > v_b)
self.assertTrue(v_d > v_c)
self.assertTrue(v_d > v_a)
self.assertGreater(v_b, v_a)
self.assertGreater(v_c, v_b)
self.assertGreater(v_d, v_c)
self.assertGreater(v_d, v_a)
def test_commit_info(self):
"""Test that the git commit information is extracted successfully."""
@@ -1065,7 +1065,8 @@ class TestVersionNumber(TestCase):
subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8'
).strip()
self.assertEqual(hash, version.inventreeCommitHash())
# On some systems the hash is a different length, so just check the first 6 characters
self.assertEqual(hash[:6], version.inventreeCommitHash()[:6])
d = (
str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8')
@@ -1505,7 +1506,7 @@ class MagicLoginTest(InvenTreeTestCase):
self.assertEqual(mail.outbox[0].subject, '[InvenTree] Log in to the app')
# Check that the token is in the email
self.assertTrue('http://testserver/api/email/login/' in mail.outbox[0].body)
self.assertIn('http://testserver/api/email/login/', mail.outbox[0].body)
token = mail.outbox[0].body.split('/')[-1].split('\n')[0][8:]
self.assertEqual(get_user(token), self.user)
+119 -68
View File
@@ -4,6 +4,7 @@ import csv
import io
import json
import re
import time
from contextlib import contextmanager
from pathlib import Path
@@ -141,7 +142,26 @@ class UserMixin:
def setUp(self):
"""Run setup for individual test methods."""
if self.auto_login:
self.client.login(username=self.username, password=self.password)
self.login()
def login(self):
"""Login with the current user credentials."""
self.client.login(username=self.username, password=self.password)
def logout(self):
"""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):
@@ -228,10 +248,19 @@ class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Base class for running InvenTree API tests."""
# Default query count threshold value
# TODO: This value should be reduced
MAX_QUERY_COUNT = 250
WARNING_QUERY_THRESHOLD = 100
# Default query time threshold value
# TODO: This value should be reduced
# Note: There is a lot of variability in the query time in unit testing...
MAX_QUERY_TIME = 7.5
@contextmanager
def assertNumQueriesLessThan(
self, value, using='default', verbose=False, debug=False
):
def assertNumQueriesLessThan(self, value, using='default', verbose=None, url=None):
"""Context manager to check that the number of queries is less than a certain value.
Example:
@@ -242,41 +271,43 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
with CaptureQueriesContext(connections[using]) as context:
yield # your test will be run here
if verbose:
n = len(context.captured_queries)
if url and n >= value:
print(
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
) # pragma: no cover
if verbose or n >= value:
msg = '\r\n%s' % json.dumps(
context.captured_queries, indent=4
) # pragma: no cover
else:
msg = None
n = len(context.captured_queries)
if debug:
print(
f'Expected less than {value} queries, got {n} queries'
) # pragma: no cover
if url and n > self.WARNING_QUERY_THRESHOLD:
print(f'Warning: {n} queries executed at {url}')
self.assertLess(n, value, msg=msg)
def checkResponse(self, url, method, expected_code, response):
def check_response(self, url, response, expected_code=None):
"""Debug output for an unexpected response."""
# No expected code, return
if expected_code is None:
return
# Check that the response returned the expected status code
if expected_code != response.status_code: # pragma: no cover
print(
f"Unexpected {method} response at '{url}': status_code = {response.status_code}"
)
if expected_code is not None:
if expected_code != response.status_code: # pragma: no cover
print(
f"Unexpected response at '{url}': status_code = {response.status_code} (expected {expected_code})"
)
if hasattr(response, 'data'):
print('data:', response.data)
if hasattr(response, 'body'):
print('body:', response.body)
if hasattr(response, 'content'):
print('content:', response.content)
if hasattr(response, 'data'):
print('data:', response.data)
if hasattr(response, 'body'):
print('body:', response.body)
if hasattr(response, 'content'):
print('content:', response.content)
self.assertEqual(expected_code, response.status_code)
self.assertEqual(response.status_code, expected_code)
def getActions(self, url):
"""Return a dict of the 'actions' available at a given endpoint.
@@ -289,72 +320,88 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
actions = response.data.get('actions', {})
return actions
def get(self, url, data=None, expected_code=200, format='json', **kwargs):
def query(self, url, method, data=None, **kwargs):
"""Perform a generic API query."""
if data is None:
data = {}
kwargs['format'] = kwargs.get('format', 'json')
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
self.check_response(url, response, expected_code=expected_code)
if dt > max_query_time:
print(
f'Query time exceeded at {url}: Expected {max_query_time}s, got {dt}s'
)
self.assertLessEqual(dt, max_query_time)
return response
def get(self, url, data=None, expected_code=200, **kwargs):
"""Issue a GET request."""
# Set default - see B006
if data is None:
data = {}
kwargs['data'] = data
response = self.client.get(url, data, format=format, **kwargs)
return self.query(url, self.client.get, expected_code=expected_code, **kwargs)
self.checkResponse(url, 'GET', expected_code, response)
return response
def post(self, url, data=None, expected_code=None, format='json', **kwargs):
def post(self, url, data=None, expected_code=201, **kwargs):
"""Issue a POST request."""
# Set default value - see B006
if data is None:
data = {}
# Default query limit is higher for POST requests, due to extra event processing
kwargs['max_query_count'] = kwargs.get(
'max_query_count', self.MAX_QUERY_COUNT + 100
)
response = self.client.post(url, data=data, format=format, **kwargs)
kwargs['data'] = data
self.checkResponse(url, 'POST', expected_code, response)
return self.query(url, self.client.post, expected_code=expected_code, **kwargs)
return response
def delete(self, url, data=None, expected_code=None, format='json', **kwargs):
def delete(self, url, data=None, expected_code=204, **kwargs):
"""Issue a DELETE request."""
if data is None:
data = {}
kwargs['data'] = data
response = self.client.delete(url, data=data, format=format, **kwargs)
return self.query(
url, self.client.delete, expected_code=expected_code, **kwargs
)
self.checkResponse(url, 'DELETE', expected_code, response)
return response
def patch(self, url, data, expected_code=None, format='json', **kwargs):
def patch(self, url, data, expected_code=200, **kwargs):
"""Issue a PATCH request."""
response = self.client.patch(url, data=data, format=format, **kwargs)
kwargs['data'] = data
self.checkResponse(url, 'PATCH', expected_code, response)
return self.query(url, self.client.patch, expected_code=expected_code, **kwargs)
return response
def put(self, url, data, expected_code=None, format='json', **kwargs):
def put(self, url, data, expected_code=200, **kwargs):
"""Issue a PUT request."""
response = self.client.put(url, data=data, format=format, **kwargs)
kwargs['data'] = data
self.checkResponse(url, 'PUT', expected_code, response)
return response
return self.query(url, self.client.put, expected_code=expected_code, **kwargs)
def options(self, url, expected_code=None, **kwargs):
"""Issue an OPTIONS request."""
response = self.client.options(url, format='json', **kwargs)
kwargs['data'] = kwargs.get('data', None)
self.checkResponse(url, 'OPTIONS', expected_code, response)
return response
return self.query(
url, self.client.options, expected_code=expected_code, **kwargs
)
def download_file(
self, url, data, expected_code=None, expected_fn=None, decode=True
self, url, data, expected_code=None, expected_fn=None, decode=True, **kwargs
):
"""Download a file from the server, and return an in-memory file."""
response = self.client.get(url, data=data, format='json')
self.checkResponse(url, 'DOWNLOAD_FILE', expected_code, response)
self.check_response(url, response, expected_code=expected_code)
# Check that the response is of the correct type
if not isinstance(response, StreamingHttpResponse):
@@ -397,7 +444,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
):
"""Helper function to process and validate a downloaded csv file."""
# Check that the correct object type has been passed
self.assertTrue(isinstance(file_object, io.StringIO))
self.assertIsInstance(file_object, io.StringIO)
file_object.seek(0)
@@ -435,3 +482,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
data.append(entry)
return data
def assertDictContainsSubset(self, a, b):
"""Assert that dictionary 'a' is a subset of dictionary 'b'."""
self.assertEqual(b, b | a)
+16 -2
View File
@@ -21,7 +21,6 @@ from sesame.views import LoginView
import build.api
import common.api
import company.api
import label.api
import machine.api
import order.api
import part.api
@@ -86,10 +85,25 @@ apipatterns = [
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(
'generate/',
include([
path(
'batch-code/',
stock.api.GenerateBatchCode.as_view(),
name='api-generate-batch-code',
),
path(
'serial-number/',
stock.api.GenerateSerialNumber.as_view(),
name='api-generate-serial-number',
),
]),
),
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(label.api.label_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('user/', include(users.api.user_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
+25 -12
View File
@@ -3,6 +3,7 @@
Provides information on the current InvenTree version
"""
import logging
import os
import pathlib
import platform
@@ -14,18 +15,29 @@ from datetime import timedelta as td
import django
from django.conf import settings
from dulwich.repo import NotGitRepository, Repo
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = '0.15.0 dev'
INVENTREE_SW_VERSION = '0.16.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
@@ -51,17 +63,18 @@ def checkMinPythonVersion():
def inventreeInstanceName():
"""Returns the InstanceName settings for the current database."""
import common.models
from common.settings import get_global_setting
return common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE', '')
return get_global_setting('INVENTREE_INSTANCE')
def inventreeInstanceTitle():
"""Returns the InstanceTitle for the current database."""
import common.models
from common.settings import get_global_setting
if get_global_setting('INVENTREE_INSTANCE_TITLE'):
return get_global_setting('INVENTREE_INSTANCE')
if common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE_TITLE', False):
return common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE', '')
return 'InvenTree'
@@ -104,7 +117,7 @@ def inventreeDocUrl():
def inventreeAppUrl():
"""Return URL for InvenTree app site."""
return f'{inventreeDocUrl()}/app/app/'
return f'https://docs.inventree.org/app/'
def inventreeCreditsUrl():
@@ -122,9 +135,9 @@ def isInvenTreeUpToDate():
A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION"
"""
import common.models
from common.settings import get_global_setting
latest = common.models.InvenTreeSetting.get_setting(
latest = get_global_setting(
'_INVENTREE_LATEST_VERSION', backup_value=None, create=False
)
+3 -3
View File
@@ -25,8 +25,8 @@ from allauth.socialaccount.views import ConnectionsView
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
import common.currency
import common.models as common_models
import common.settings as common_settings
from part.models import PartCategory
from users.models import RuleSet, check_user_role
@@ -506,8 +506,8 @@ class SettingsView(TemplateView):
ctx['settings'] = common_models.InvenTreeSetting.objects.all().order_by('key')
ctx['base_currency'] = common_settings.currency_code_default()
ctx['currencies'] = common_settings.currency_codes
ctx['base_currency'] = common.currency.currency_code_default()
ctx['currencies'] = common.currency.currency_codes
ctx['rates'] = Rate.objects.filter(backend='InvenTreeExchange')
+1 -1
View File
@@ -18,7 +18,7 @@ class BuildResource(InvenTreeResource):
# TODO: 2022-05-12 - Need to investigate why this is the case!
class Meta:
"""Metaclass options"""
"""Metaclass options."""
models = Build
skip_unchanged = True
report_skipped = False
+30 -35
View File
@@ -11,16 +11,16 @@ from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.api import APIDownloadMixin, MetadataView
from generic.states.api import StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.status_codes import BuildStatus, BuildStatusGroups
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
@@ -30,7 +30,7 @@ class BuildFilter(rest_filters.FilterSet):
"""Custom filterset for BuildList API endpoint."""
class Meta:
"""Metaclass options"""
"""Metaclass options."""
model = Build
fields = [
'parent',
@@ -103,15 +103,35 @@ class BuildFilter(rest_filters.FilterSet):
return queryset.filter(project_code=None)
class BuildList(APIDownloadMixin, ListCreateAPI):
class BuildMixin:
"""Mixin class for Build API endpoints."""
queryset = Build.objects.all()
serializer_class = build.serializers.BuildSerializer
def get_queryset(self):
"""Return the queryset for the Build API endpoints."""
queryset = super().get_queryset()
queryset = queryset.prefetch_related(
'responsible',
'issued_by',
'build_lines',
'build_lines__bom_item',
'build_lines__build',
'part',
)
return queryset
class BuildList(APIDownloadMixin, BuildMixin, ListCreateAPI):
"""API endpoint for accessing a list of Build objects.
- GET: Return list of objects (with filters)
- POST: Create a new Build object
"""
queryset = Build.objects.all()
serializer_class = build.serializers.BuildSerializer
filterset_class = BuildFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
@@ -223,12 +243,9 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
return self.serializer_class(*args, **kwargs)
class BuildDetail(RetrieveUpdateDestroyAPI):
class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a Build object."""
queryset = Build.objects.all()
serializer_class = build.serializers.BuildSerializer
def destroy(self, request, *args, **kwargs):
"""Only allow deletion of a BuildOrder if the build status is CANCELLED"""
build = self.get_object()
@@ -363,6 +380,8 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
search_fields = [
'bom_item__sub_part__name',
'bom_item__sub_part__IPN',
'bom_item__sub_part__description',
'bom_item__reference',
]
@@ -595,32 +614,8 @@ class BuildItemList(ListCreateAPI):
]
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
filterset_fields = [
'build',
]
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'),
@@ -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'),
),
]
@@ -23,6 +23,7 @@ class Migration(migrations.Migration):
],
options={
'unique_together': {('build', 'bom_item')},
'verbose_name': 'Build Order Line Item',
},
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.12 on 2024-05-08 01:38
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('build', '0048_build_project_code'),
]
operations = [
migrations.AlterField(
model_name='builditem',
name='build_line',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='build.buildline'),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 4.2.12 on 2024-05-08 01:38
from django.db import migrations
def forward(apps, schema_editor):
"""Find and delete any BuildItem instances which have a null BuildLine field."""
BuildItem = apps.get_model('build', 'BuildItem')
items = BuildItem.objects.filter(build_line=None)
if items.count() > 0:
print(f"Deleting {items.count()} BuildItem objects with null BuildLine field")
items.delete()
class Migration(migrations.Migration):
dependencies = [
('build', '0049_alter_builditem_build_line'),
]
operations = [
migrations.RunPython(forward, reverse_code=migrations.RunPython.noop),
]
@@ -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',
),
]
+93 -32
View File
@@ -22,7 +22,8 @@ from mptt.exceptions import InvalidMove
from rest_framework import serializers
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode, BuildStatusGroups
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
@@ -35,9 +36,11 @@ import InvenTree.tasks
import common.models
from common.notifications import trigger_notification, InvenTreeNotificationBodies
from common.settings import get_global_setting
from plugin.events import trigger_event
import part.models
import report.mixins
import stock.models
import users.models
@@ -45,7 +48,15 @@ import users.models
logger = logging.getLogger('inventree')
class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, MPTTModel):
class Build(
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
InvenTree.models.ReferenceIndexingMixin,
MPTTModel):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes:
@@ -109,6 +120,12 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
self.validate_reference_field(self.reference)
self.reference_int = self.rebuild_reference_field(self.reference)
# 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()
try:
super().save(*args, **kwargs)
except InvalidMove:
@@ -121,7 +138,7 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
super().clean()
if common.models.InvenTreeSetting.get_setting('BUILDORDER_REQUIRE_RESPONSIBLE'):
if get_global_setting('BUILDORDER_REQUIRE_RESPONSIBLE'):
if not self.responsible:
raise ValidationError({
'responsible': _('Responsible user or group must be specified')
@@ -133,6 +150,21 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
'part': _('Build order part cannot be changed')
})
def report_context(self) -> dict:
"""Generate custom report context data."""
return {
'bom_items': self.part.get_bom_items(),
'build': self,
'build_outputs': self.build_outputs.all(),
'line_items': self.build_lines.all(),
'part': self.part,
'quantity': self.quantity,
'reference': self.reference,
'title': str(self)
}
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'.
@@ -538,7 +570,7 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
self.allocated_stock.delete()
@transaction.atomic
def complete_build(self, user):
def complete_build(self, user, trim_allocated_stock=False):
"""Mark this build as complete."""
import build.tasks
@@ -546,17 +578,21 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
if self.incomplete_count > 0:
return
if trim_allocated_stock:
self.trim_allocated_stock()
self.completion_date = InvenTree.helpers.current_date()
self.completed_by = user
self.status = BuildStatus.COMPLETE.value
self.save()
# Offload task to complete build allocations
InvenTree.tasks.offload_task(
if not InvenTree.tasks.offload_task(
build.tasks.complete_build_allocations,
self.pk,
user.pk if user else None
)
):
raise ValidationError(_("Failed to offload task to complete build allocations"))
# Register an event
trigger_event('build.completed', id=self.pk)
@@ -608,24 +644,29 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
- Set build status to CANCELLED
- Save the Build object
"""
import build.tasks
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
# Find all BuildItem objects associated with this Build
items = self.allocated_stock
if remove_allocated_stock:
for item in items:
item.complete_allocation(user)
# Offload task to remove allocated stock
if not InvenTree.tasks.offload_task(
build.tasks.complete_build_allocations,
self.pk,
user.pk if user else None
):
raise ValidationError(_("Failed to offload task to complete build allocations"))
items.delete()
else:
self.allocated_stock.all().delete()
# Remove incomplete outputs (if required)
if remove_incomplete_outputs:
outputs = self.build_outputs.filter(is_building=True)
for output in outputs:
output.delete()
outputs.delete()
# Date of 'completion' is the date the build was cancelled
self.completion_date = InvenTree.helpers.current_date()
@@ -676,10 +717,13 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
"""
user = kwargs.get('user', None)
batch = kwargs.get('batch', self.batch)
location = kwargs.get('location', self.destination)
location = kwargs.get('location', None)
serials = kwargs.get('serials', None)
auto_allocate = kwargs.get('auto_allocate', False)
if location is None:
location = self.destination or self.part.get_default_location()
"""
Determine if we can create a single output (with quantity > 0),
or multiple outputs (with quantity = 1)
@@ -820,6 +864,10 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
def trim_allocated_stock(self):
"""Called after save to reduce allocated stock if the build order is now overallocated."""
# Only need to worry about untracked stock here
items_to_save = []
items_to_delete = []
for build_line in self.untracked_line_items:
reduce_by = build_line.allocated_quantity() - build_line.quantity
@@ -837,13 +885,19 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
# Easy case - this item can just be reduced.
if item.quantity > reduce_by:
item.quantity -= reduce_by
item.save()
items_to_save.append(item)
break
# Harder case, this item needs to be deleted, and any remainder
# taken from the next items in the list.
reduce_by -= item.quantity
item.delete()
items_to_delete.append(item)
# Save the updated BuildItem objects
BuildItem.objects.bulk_update(items_to_save, ['quantity'])
# Delete the remaining BuildItem objects
BuildItem.objects.filter(pk__in=[item.pk for item in items_to_delete]).delete()
@property
def allocated_stock(self):
@@ -940,7 +994,10 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
# List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all()
if (common.settings.prevent_build_output_complete_on_incompleted_tests() and output.hasRequiredTests() and not output.passedAllRequiredTests()):
required_tests = kwargs.get('required_tests', output.part.getRequiredTests())
prevent_on_incomplete = kwargs.get('prevent_on_incomplete', common.settings.prevent_build_output_complete_on_incompleted_tests())
if (prevent_on_incomplete and not output.passedAllRequiredTests(required_tests=required_tests)):
serial = output.serial
raise ValidationError(
_(f"Build output {serial} has not passed all required tests"))
@@ -1266,17 +1323,7 @@ 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(InvenTree.models.InvenTreeModel):
class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically.
@@ -1293,7 +1340,8 @@ class BuildLine(InvenTree.models.InvenTreeModel):
"""
class Meta:
"""Model meta options"""
"""Model meta options."""
verbose_name = _('Build Order Line Item')
unique_together = [
('build', 'bom_item'),
]
@@ -1303,6 +1351,19 @@ class BuildLine(InvenTree.models.InvenTreeModel):
"""Return the API URL used to access this model"""
return reverse('api-build-line-list')
def report_context(self):
"""Generate custom report context for this BuildLine object."""
return {
'allocated_quantity': self.allocated_quantity,
'allocations': self.allocations,
'bom_item': self.bom_item,
'build': self.build,
'build_line': self,
'part': self.bom_item.sub_part,
'quantity': self.quantity,
}
build = models.ForeignKey(
Build, on_delete=models.CASCADE,
related_name='build_lines', help_text=_('Build object')
@@ -1369,7 +1430,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
"""
class Meta:
"""Model meta options"""
"""Model meta options."""
unique_together = [
('build_line', 'stock_item', 'install_into'),
]
@@ -1554,7 +1615,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
build_line = models.ForeignKey(
BuildLine,
on_delete=models.SET_NULL, null=True,
on_delete=models.CASCADE, null=True,
related_name='allocations',
)
+73 -43
View File
@@ -1,7 +1,5 @@
"""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 _
@@ -15,16 +13,14 @@ from django.db.models.functions import Coalesce
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubquerySum
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
from InvenTree.status_codes import BuildStatusGroups, StockStatus
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
from stock.status_codes import StockStatus
from stock.models import generate_batch_code, StockItem, StockLocation
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer
import common.models
@@ -33,10 +29,10 @@ import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
from .models import Build, BuildLine, BuildItem
class BuildSerializer(InvenTreeModelSerializer):
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
"""Serializes a Build object."""
class Meta:
@@ -239,6 +235,16 @@ class BuildOutputCreateSerializer(serializers.Serializer):
The Build object is provided to the serializer context.
"""
class Meta:
"""Serializer metaclass."""
fields = [
'quantity',
'batch_code',
'serial_numbers',
'location',
'auto_allocate',
]
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
@@ -288,6 +294,13 @@ class BuildOutputCreateSerializer(serializers.Serializer):
help_text=_('Enter serial numbers for build outputs'),
)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
label=_('Location'),
help_text=_('Stock location for build output'),
required=False, allow_null=True
)
def validate_serial_numbers(self, serial_numbers):
"""Clean the provided serial number string"""
serial_numbers = serial_numbers.strip()
@@ -312,6 +325,11 @@ class BuildOutputCreateSerializer(serializers.Serializer):
quantity = data['quantity']
serial_numbers = data.get('serial_numbers', '')
if part.trackable and not serial_numbers:
raise ValidationError({
'serial_numbers': _('Serial numbers must be provided for trackable parts')
})
if serial_numbers:
try:
@@ -348,19 +366,15 @@ class BuildOutputCreateSerializer(serializers.Serializer):
"""Generate the new build output(s)"""
data = self.validated_data
quantity = data['quantity']
batch_code = data.get('batch_code', '')
auto_allocate = data.get('auto_allocate', False)
build = self.get_build()
user = self.context['request'].user
build.create_build_output(
quantity,
data['quantity'],
serials=self.serials,
batch=batch_code,
auto_allocate=auto_allocate,
user=user,
batch=data.get('batch_code', ''),
location=data.get('location', None),
auto_allocate=data.get('auto_allocate', False),
user=self.context['request'].user,
)
@@ -553,10 +567,13 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
outputs = data.get('outputs', [])
# Cache some calculated values which can be passed to each output
required_tests = outputs[0]['output'].part.getRequiredTests()
prevent_on_incomplete = common.settings.prevent_build_output_complete_on_incompleted_tests()
# Mark the specified build outputs as "complete"
with transaction.atomic():
for item in outputs:
output = item['output']
build.complete_build_output(
@@ -565,6 +582,8 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
location=location,
status=status,
notes=notes,
required_tests=required_tests,
prevent_on_incomplete=prevent_on_incomplete,
)
@@ -589,8 +608,8 @@ class BuildCancelSerializer(serializers.Serializer):
}
remove_allocated_stock = serializers.BooleanField(
label=_('Remove Allocated Stock'),
help_text=_('Subtract any stock which has already been allocated to this build'),
label=_('Consume Allocated Stock'),
help_text=_('Consume any stock which has already been allocated to this build'),
required=False,
default=False,
)
@@ -611,7 +630,7 @@ class BuildCancelSerializer(serializers.Serializer):
build.cancel_build(
request.user,
remove_allocated_stock=data.get('remove_unallocated_stock', False),
remove_allocated_stock=data.get('remove_allocated_stock', False),
remove_incomplete_outputs=data.get('remove_incomplete_outputs', False),
)
@@ -633,6 +652,14 @@ class OverallocationChoice():
class BuildCompleteSerializer(serializers.Serializer):
"""DRF serializer for marking a BuildOrder as complete."""
class Meta:
"""Serializer metaclass"""
fields = [
'accept_overallocated',
'accept_unallocated',
'accept_incomplete',
]
def get_context_data(self):
"""Retrieve extra context data for this serializer.
@@ -711,10 +738,11 @@ class BuildCompleteSerializer(serializers.Serializer):
build = self.context['build']
data = self.validated_data
if data.get('accept_overallocated', OverallocationChoice.REJECT) == OverallocationChoice.TRIM:
build.trim_allocated_stock()
build.complete_build(request.user)
build.complete_build(
request.user,
trim_allocated_stock=data.get('accept_overallocated', OverallocationChoice.REJECT) == OverallocationChoice.TRIM
)
class BuildUnallocationSerializer(serializers.Serializer):
@@ -726,6 +754,13 @@ class BuildUnallocationSerializer(serializers.Serializer):
- bom_item: Filter against a particular BOM line item
"""
class Meta:
"""Serializer metaclass"""
fields = [
'build_line',
'output',
]
build_line = serializers.PrimaryKeyRelatedField(
queryset=BuildLine.objects.all(),
many=False,
@@ -994,17 +1029,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
def save(self):
"""Perform the auto-allocation step"""
import build.tasks
import InvenTree.tasks
data = self.validated_data
build = self.context['build']
build_order = self.context['build']
build.auto_allocate_stock(
if not InvenTree.tasks.offload_task(
build.tasks.auto_allocate_build,
build_order.pk,
location=data.get('location', None),
exclude_location=data.get('exclude_location', None),
interchangeable=data['interchangeable'],
substitutes=data['substitutes'],
optional_items=data['optional_items'],
)
optional_items=data['optional_items']
):
raise ValidationError(_("Failed to start auto-allocation task"))
class BuildItemSerializer(InvenTreeModelSerializer):
@@ -1268,15 +1310,3 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
return queryset
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for a BuildAttachment."""
class Meta:
"""Serializer metaclass"""
model = BuildOrderAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields([
'build',
])
@@ -0,0 +1,20 @@
"""Build status codes."""
from django.utils.translation import gettext_lazy as _
from generic.states import StatusCode
class BuildStatus(StatusCode):
"""Build status codes."""
PENDING = 10, _('Pending'), 'secondary' # Build is pending / active
PRODUCTION = 20, _('Production'), 'primary' # BuildOrder is in production
CANCELLED = 30, _('Cancelled'), 'danger' # Build was cancelled
COMPLETE = 40, _('Complete'), 'success' # Build is complete
class BuildStatusGroups:
"""Groups for BuildStatus codes."""
ACTIVE_CODES = [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value]
+13 -1
View File
@@ -17,8 +17,8 @@ import InvenTree.email
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.tasks
from InvenTree.status_codes import BuildStatusGroups
from InvenTree.ready import isImportingData
from build.status_codes import BuildStatusGroups
import part.models as part_models
@@ -26,6 +26,18 @@ import part.models as part_models
logger = logging.getLogger('inventree')
def auto_allocate_build(build_id: int, **kwargs):
"""Run auto-allocation for a specified BuildOrder."""
build_order = build.models.Build.objects.filter(pk=build_id).first()
if not build_order:
logger.warning("Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist", build_id)
return
build_order.auto_allocate_stock(**kwargs)
def complete_build_allocations(build_id: int, user_id: int):
"""Complete build allocations for a specified BuildOrder."""
@@ -257,11 +257,7 @@ src="{% static 'img/blank_image.png' %}"
{% if report_enabled %}
$('#print-build-report').click(function() {
printReports({
items: [{{ build.pk }}],
key: 'build',
url: '{% url "api-build-report-list" %}',
});
printReports('build', [{{ build.pk }}]);
});
{% endif %}
@@ -326,18 +326,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() {
@@ -346,6 +335,8 @@ onPanelLoad('notes', function() {
'build-notes',
'{% url "api-build-detail" build.pk %}',
{
model_type: 'build',
model_id: {{ build.pk }},
{% if roles.build.change %}
editable: true,
{% else %}
@@ -366,6 +357,7 @@ onPanelLoad('outputs', function() {
source_location: {{ build.take_from.pk }},
{% endif %}
tracked_parts: true,
trackable: {% js_bool build.part.trackable %}
};
loadBuildOutputTable(build_info);
+53 -5
View File
@@ -10,7 +10,8 @@ from part.models import Part
from build.models import Build, BuildItem
from stock.models import StockItem
from InvenTree.status_codes import BuildStatus, StockStatus
from build.status_codes import BuildStatus
from stock.status_codes import StockStatus
from InvenTree.unit_test import InvenTreeAPITestCase
@@ -223,6 +224,7 @@ class BuildTest(BuildAPITest):
"status": 50, # Item requires attention
},
expected_code=201,
max_query_count=450, # TODO: Try to optimize this
)
self.assertEqual(self.build.incomplete_outputs.count(), 0)
@@ -247,7 +249,7 @@ class BuildTest(BuildAPITest):
expected_code=400
)
self.assertTrue('accept_unallocated' in response.data)
self.assertIn('accept_unallocated', response.data)
# Accept unallocated stock
self.post(
@@ -264,8 +266,35 @@ class BuildTest(BuildAPITest):
self.assertTrue(self.build.is_complete)
def test_cancel(self):
"""Test that we can cancel a BuildOrder via the API."""
bo = Build.objects.get(pk=1)
"""Test that we can cancel a BuildOrder via the API.
- First test that all stock is returned to stock
- Second test that stock is consumed by the build order
"""
def make_new_build(ref):
"""Make a new build order, and allocate stock to it."""
data = self.post(
reverse('api-build-list'),
{
'part': 100,
'quantity': 10,
'title': 'Test build',
'reference': ref,
},
expected_code=201
).data
build = Build.objects.get(pk=data['pk'])
build.auto_allocate_stock()
self.assertGreater(build.build_lines.count(), 0)
return build
bo = make_new_build('BO-12345')
url = reverse('api-build-cancel', kwargs={'pk': bo.pk})
@@ -277,6 +306,23 @@ class BuildTest(BuildAPITest):
self.assertEqual(bo.status, BuildStatus.CANCELLED)
# No items were "consumed" by this build
self.assertEqual(bo.consumed_stock.count(), 0)
# Make another build, this time we will *consume* the allocated stock
bo = make_new_build('BO-12346')
url = reverse('api-build-cancel', kwargs={'pk': bo.pk})
self.post(url, {'remove_allocated_stock': True}, expected_code=201)
bo.refresh_from_db()
self.assertEqual(bo.status, BuildStatus.CANCELLED)
# This time, there should be *consumed* stock
self.assertGreater(bo.consumed_stock.count(), 0)
def test_delete(self):
"""Test that we can delete a BuildOrder via the API"""
bo = Build.objects.get(pk=1)
@@ -934,7 +980,7 @@ class BuildOverallocationTest(BuildAPITest):
{},
expected_code=400
)
self.assertTrue('accept_overallocated' in response.data)
self.assertIn('accept_overallocated', response.data)
# Check stock items have not reduced at all
for si, oq, _ in self.state.values():
@@ -948,6 +994,7 @@ class BuildOverallocationTest(BuildAPITest):
'accept_overallocated': 'accept',
},
expected_code=201,
max_query_count=550, # TODO: Come back and refactor this
)
self.build.refresh_from_db()
@@ -968,6 +1015,7 @@ class BuildOverallocationTest(BuildAPITest):
'accept_overallocated': 'trim',
},
expected_code=201,
max_query_count=550, # TODO: Come back and refactor this
)
self.build.refresh_from_db()
+8 -7
View File
@@ -12,6 +12,7 @@ from django.db.models import Sum
from InvenTree import status_codes as status
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 part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
@@ -215,7 +216,7 @@ class BuildTest(BuildTestBase):
def test_ref_int(self):
"""Test the "integer reference" field used for natural sorting"""
# Set build reference to new value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
refs = {
'BO-123-456': 123,
@@ -238,7 +239,7 @@ class BuildTest(BuildTestBase):
self.assertEqual(build.reference_int, ref_int)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_ref_validation(self):
"""Test that the reference field validation works as expected"""
@@ -271,7 +272,7 @@ class BuildTest(BuildTestBase):
)
# Try a new validator pattern
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None)
for ref in [
'1234-BO',
@@ -285,11 +286,11 @@ class BuildTest(BuildTestBase):
)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_next_ref(self):
"""Test that the next reference is automatically generated"""
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
build = Build.objects.create(
part=self.assembly,
@@ -311,7 +312,7 @@ class BuildTest(BuildTestBase):
self.assertEqual(build.reference_int, 988)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_init(self):
"""Perform some basic tests before we start the ball rolling"""
@@ -647,7 +648,7 @@ class BuildTest(BuildTestBase):
"""Test the prevention completion when a required test is missing feature"""
# with required tests incompleted the save should fail
common.models.InvenTreeSetting.set_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None)
set_global_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None)
with self.assertRaises(ValidationError):
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, 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 -1
View File
@@ -11,7 +11,7 @@ from InvenTree.unit_test import InvenTreeTestCase
from .models import Build
from stock.models import StockItem
from InvenTree.status_codes import BuildStatus
from build.status_codes import BuildStatus
class BuildTestSimple(InvenTreeTestCase):
+1 -1
View File
@@ -5,7 +5,7 @@ from django.views.generic import DetailView, ListView
from .models import Build
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import BuildStatus
from build.status_codes import BuildStatus
from plugin.views import InvenTreePluginViewMixin
+43 -6
View File
@@ -5,8 +5,46 @@ 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)
class ProjectCodeAdmin(ImportExportModelAdmin):
"""Admin settings for ProjectCode."""
list_display = ('code', 'description')
search_fields = ('code', 'description')
@admin.register(common.models.InvenTreeSetting)
class SettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeSetting."""
@@ -19,6 +57,7 @@ class SettingsAdmin(ImportExportModelAdmin):
return []
@admin.register(common.models.InvenTreeUserSetting)
class UserSettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeUserSetting."""
@@ -31,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."""
@@ -61,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)
+111 -2
View File
@@ -4,24 +4,28 @@ 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.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.settings import get_global_setting
from generic.states.api import AllStatusViews, StatusView
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS
@@ -127,6 +131,7 @@ class CurrencyExchangeView(APIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = None
@extend_schema(responses={200: common.serializers.CurrencyExchangeSerializer})
def get(self, request, format=None):
"""Return information on available currency conversions."""
# Extract a list of all available rates
@@ -148,7 +153,7 @@ class CurrencyExchangeView(APIView):
updated = None
response = {
'base_currency': common.models.InvenTreeSetting.get_setting(
'base_currency': get_global_setting(
'INVENTREE_DEFAULT_CURRENCY', backup_value='USD'
),
'exchange_rates': {},
@@ -356,6 +361,22 @@ class NotificationMessageMixin:
serializer_class = common.serializers.NotificationMessageSerializer
permission_classes = [UserSettingsPermissions]
def get_queryset(self):
"""Return prefetched queryset."""
queryset = (
super()
.get_queryset()
.prefetch_related(
'source_content_type',
'source_object',
'target_content_type',
'target_object',
'user',
)
)
return queryset
class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
"""List view for all notifications of the current user."""
@@ -462,6 +483,10 @@ class NotesImageList(ListCreateAPI):
serializer_class = common.serializers.NotesImageSerializer
permission_classes = [permissions.IsAuthenticated]
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['user', 'model_type', 'model_id']
def perform_create(self, serializer):
"""Create (upload) a new notes image."""
image = serializer.save()
@@ -652,6 +677,71 @@ 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)
settings_api_urls = [
# User settings
path(
@@ -720,6 +810,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([
+3 -6
View File
@@ -5,6 +5,7 @@ import logging
from django.apps import AppConfig
import InvenTree.ready
from common.settings import get_global_setting, set_global_setting
logger = logging.getLogger('inventree')
@@ -27,16 +28,12 @@ class CommonConfig(AppConfig):
def clear_restart_flag(self):
"""Clear the SERVER_RESTART_REQUIRED setting."""
try:
import common.models
if common.models.InvenTreeSetting.get_setting(
if get_global_setting(
'SERVER_RESTART_REQUIRED', backup_value=False, create=False, cache=False
):
logger.info('Clearing SERVER_RESTART_REQUIRED flag')
if not InvenTree.ready.isImportingData():
common.models.InvenTreeSetting.set_setting(
'SERVER_RESTART_REQUIRED', False, None
)
set_global_setting('SERVER_RESTART_REQUIRED', False, None)
except Exception:
pass
+230
View File
@@ -0,0 +1,230 @@
"""Helper functions for currency support."""
import decimal
import logging
import math
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from moneyed import CURRENCIES
import InvenTree.helpers
logger = logging.getLogger('inventree')
def currency_code_default():
"""Returns the default currency code (or USD if not specified)."""
from common.settings import get_global_setting
try:
cached_value = cache.get('currency_code_default', '')
except Exception:
cached_value = None
if cached_value:
return cached_value
try:
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 = ''
if code not in CURRENCIES:
code = 'USD' # pragma: no cover
# Cache the value for a short amount of time
try:
cache.set('currency_code_default', code, 30)
except Exception:
pass
return code
def all_currency_codes() -> list:
"""Returns a list of all currency codes."""
return [(a, CURRENCIES[a].name) for a in CURRENCIES]
def currency_codes_default_list() -> str:
"""Return a comma-separated list of default currency codes."""
return 'AUD,CAD,CNY,EUR,GBP,JPY,NZD,USD'
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()
if not codes:
codes = currency_codes_default_list()
codes = codes.split(',')
valid_codes = []
for code in codes:
code = code.strip().upper()
if code in valid_codes:
continue
if code in CURRENCIES:
valid_codes.append(code)
else:
logger.warning(f"Invalid currency code: '{code}'")
if len(valid_codes) == 0:
valid_codes = list(currency_codes_default_list().split(','))
return valid_codes
def currency_code_mappings() -> list:
"""Returns the current currency choices."""
return [(a, CURRENCIES[a].name) for a in currency_codes()]
def after_change_currency(setting) -> None:
"""Callback function when base currency is changed.
- Update exchange rates
- Recalculate prices for all parts
"""
import InvenTree.ready
import InvenTree.tasks
if InvenTree.ready.isImportingData():
return
if not InvenTree.ready.canAppAccessDatabase():
return
from part import tasks as part_tasks
# Immediately update exchange rates
InvenTree.tasks.update_exchange_rates(force=True)
# Offload update of part prices to a background task
InvenTree.tasks.offload_task(part_tasks.check_missing_pricing, force_async=True)
def validate_currency_codes(value):
"""Validate the currency codes."""
values = value.strip().split(',')
valid_currencies = set()
for code in values:
code = code.strip().upper()
if not code:
continue
if code not in CURRENCIES:
raise ValidationError(_('Invalid currency code') + f": '{code}'")
elif code in valid_currencies:
raise ValidationError(_('Duplicate currency code') + f": '{code}'")
else:
valid_currencies.add(code)
if len(valid_currencies) == 0:
raise ValidationError(_('No valid currency codes provided'))
return list(valid_currencies)
def currency_exchange_plugins() -> list:
"""Return a list of plugin choices which can be used for currency exchange."""
try:
from plugin import registry
plugs = registry.with_mixin('currencyexchange', active=True)
except Exception:
plugs = []
if len(plugs) == 0:
return None
return [('', _('No plugin'))] + [(plug.slug, plug.human_name) for plug in plugs]
def get_price(
instance,
quantity,
moq=True,
multiples=True,
currency=None,
break_name: str = 'price_breaks',
):
"""Calculate the price based on quantity price breaks.
- Don't forget to add in flat-fee cost (base_cost field)
- If MOQ (minimum order quantity) is required, bump quantity
- If order multiples are to be observed, then we need to calculate based on that, too
"""
from common.currency import currency_code_default
if hasattr(instance, break_name):
price_breaks = getattr(instance, break_name).all()
else:
price_breaks = []
# No price break information available?
if len(price_breaks) == 0:
return None
# Check if quantity is fraction and disable multiples
multiples = quantity % 1 == 0
# Order multiples
if multiples:
quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple)
pb_found = False
pb_quantity = -1
pb_cost = 0.0
if currency is None:
# Default currency selection
currency = currency_code_default()
pb_min = None
for pb in price_breaks:
# Store smallest price break
if not pb_min:
pb_min = pb
# Ignore this pricebreak (quantity is too high)
if pb.quantity > quantity:
continue
pb_found = True
# If this price-break quantity is the largest so far, use it!
if pb.quantity > pb_quantity:
pb_quantity = pb.quantity
# Convert everything to the selected currency
pb_cost = pb.convert_to(currency)
# Use smallest price break
if not pb_found and pb_min:
# Update price break information
pb_quantity = pb_min.quantity
pb_cost = pb_min.convert_to(currency)
# Trigger cost calculation using smallest price break
pb_found = True
# Convert quantity to decimal.Decimal format
quantity = decimal.Decimal(f'{quantity}')
if pb_found:
cost = pb_cost * quantity
return InvenTree.helpers.normalize(cost + instance.base_cost)
return None
@@ -9,7 +9,7 @@ def set_default_currency(apps, schema_editor):
# get value from settings-file
base_currency = get_setting('INVENTREE_BASE_CURRENCY', 'base_currency', 'USD')
from common.settings import currency_codes
from common.currency import currency_codes
# check if value is valid
if base_currency not in currency_codes():
@@ -0,0 +1,73 @@
# Generated by Django 4.2.12 on 2024-06-02 13:32
from django.db import migrations
from moneyed import CURRENCIES
import InvenTree.config
def set_currencies(apps, schema_editor):
"""Set the default currency codes.
Ref: https://github.com/inventree/InvenTree/pull/7390
Previously, the allowed currency codes were set in the external configuration
(e.g via the configuration file or environment variables).
Now, they are set in the database (via the InvenTreeSetting model).
So, this data migration exists to transfer any configured currency codes,
from the external configuration, into the database settings model.
"""
InvenTreeSetting = apps.get_model('common', 'InvenTreeSetting')
key = 'CURRENCY_CODES'
codes = InvenTree.config.get_setting('INVENTREE_CURRENCIES', 'currencies', None)
if codes is None:
# No currency codes are defined in the configuration file
return
if type(codes) == str:
codes = codes.split(',')
valid_codes = set()
for code in codes:
code = code.strip().upper()
if code in CURRENCIES:
valid_codes.add(code)
if len(valid_codes) == 0:
print(f"No valid currency codes found in configuration file")
return
value = ','.join(valid_codes)
print(f"Found existing currency codes:", value)
setting = InvenTreeSetting.objects.filter(key=key).first()
if setting:
print(f"- Updating existing setting for currency codes")
setting.value = value
setting.save()
else:
print(f"- Creating new setting for currency codes")
setting = InvenTreeSetting(key=key, value=value)
setting.save()
class Migration(migrations.Migration):
dependencies = [
('common', '0022_projectcode_responsible'),
]
operations = [
migrations.RunPython(set_currencies, reverse_code=migrations.RunPython.noop)
]
@@ -0,0 +1,25 @@
# Generated by Django 4.2.12 on 2024-05-22 12:27
import common.validators
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0023_auto_20240602_1332'),
]
operations = [
migrations.AddField(
model_name='notesimage',
name='model_id',
field=models.IntegerField(blank=True, default=None, help_text='Target model ID for this image', null=True),
),
migrations.AddField(
model_name='notesimage',
name='model_type',
field=models.CharField(blank=True, null=True, help_text='Target model type for this image', max_length=100, validators=[common.validators.validate_notes_model_type]),
),
]
@@ -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),
]
+293 -180
View File
@@ -4,28 +4,27 @@ These models are 'generic' and do not fit a particular business logic object.
"""
import base64
import decimal
import hashlib
import hmac
import json
import logging
import math
import os
import re
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
from django.apps import apps
from django.conf import settings
from django.conf import settings as django_settings
from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.fields import GenericForeignKey
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 AppRegistryNotReady, ValidationError
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
@@ -37,10 +36,12 @@ from django.utils.translation import gettext_lazy as _
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.settings import CURRENCY_CHOICES
from rest_framework.exceptions import PermissionDenied
from taggit.managers import TaggableManager
import build.validators
import common.currency
import common.validators
import InvenTree.fields
import InvenTree.helpers
import InvenTree.models
@@ -50,6 +51,7 @@ import InvenTree.validators
import order.validators
import report.helpers
import users.models
from InvenTree.sanitizer import sanitize_svg
from plugin import registry
logger = logging.getLogger('inventree')
@@ -101,7 +103,7 @@ class BaseURLValidator(URLValidator):
value = str(value).strip()
# If a configuration level value has been specified, prevent change
if settings.SITE_URL and value != settings.SITE_URL:
if django_settings.SITE_URL and value != django_settings.SITE_URL:
raise ValidationError(_('Site URL is locked by configuration'))
if len(value) == 0:
@@ -551,25 +553,25 @@ class BaseInvenTreeSetting(models.Model):
"""
key = str(key).strip().upper()
# 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)
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', False)
# Prevent saving to the database during data import
if InvenTree.ready.isImportingData():
create = False
do_cache = False
# 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
@@ -596,33 +598,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
@@ -696,6 +686,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 = {
@@ -749,6 +748,7 @@ class BaseInvenTreeSetting(models.Model):
attempts=attempts - 1,
**kwargs,
)
except (OperationalError, ProgrammingError):
logger.warning("Database is locked, cannot set setting '%s'", key)
# Likely the DB is locked - not much we can do here
@@ -1116,7 +1116,7 @@ def settings_group_options():
def update_instance_url(setting):
"""Update the first site objects domain to url."""
if not settings.SITE_MULTI:
if not django_settings.SITE_MULTI:
return
try:
@@ -1132,7 +1132,7 @@ def update_instance_url(setting):
def update_instance_name(setting):
"""Update the first site objects name to instance name."""
if not settings.SITE_MULTI:
if not django_settings.SITE_MULTI:
return
try:
@@ -1146,52 +1146,6 @@ def update_instance_name(setting):
site_obj.save()
def validate_email_domains(setting):
"""Validate the email domains setting."""
if not setting.value:
return
domains = setting.value.split(',')
for domain in domains:
if not domain:
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 currency_exchange_plugins():
"""Return a set of plugin choices which can be used for currency exchange."""
try:
from plugin import registry
plugs = registry.with_mixin('currencyexchange', active=True)
except Exception:
plugs = []
return [('', _('No plugin'))] + [(plug.slug, plug.human_name) for plug in plugs]
def after_change_currency(setting):
"""Callback function when base currency is changed.
- Update exchange rates
- Recalculate prices for all parts
"""
if InvenTree.ready.isImportingData():
return
if not InvenTree.ready.canAppAccessDatabase():
return
from part import tasks as part_tasks
# Immediately update exchange rates
InvenTree.tasks.update_exchange_rates(force=True)
# Offload update of part prices to a background task
InvenTree.tasks.offload_task(part_tasks.check_missing_pricing, force_async=True)
def reload_plugin_registry(setting):
"""When a core plugin setting is changed, reload the plugin registry."""
from plugin import registry
@@ -1304,8 +1258,15 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Default Currency'),
'description': _('Select base currency for pricing calculations'),
'default': 'USD',
'choices': CURRENCY_CHOICES,
'after_save': after_change_currency,
'choices': common.currency.currency_code_mappings,
'after_save': common.currency.after_change_currency,
},
'CURRENCY_CODES': {
'name': _('Supported Currencies'),
'description': _('List of supported currency codes'),
'default': common.currency.currency_codes_default_list(),
'validator': common.currency.validate_currency_codes,
'after_save': common.currency.after_change_currency,
},
'CURRENCY_UPDATE_INTERVAL': {
'name': _('Currency Update Interval'),
@@ -1319,7 +1280,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'CURRENCY_UPDATE_PLUGIN': {
'name': _('Currency Update Plugin'),
'description': _('Currency update plugin to use'),
'choices': currency_exchange_plugins,
'choices': common.currency.currency_exchange_plugins,
'default': 'inventreecurrencyexchange',
},
'INVENTREE_DOWNLOAD_FROM_URL': {
@@ -1436,6 +1397,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
'default': True,
},
'PART_ALLOW_DELETE_FROM_ASSEMBLY': {
'name': _('Allow Deletion from Assembly'),
'description': _('Allow deletion of parts which are used in an assembly'),
'validator': bool,
'default': False,
},
'PART_IPN_REGEX': {
'name': _('IPN Regex'),
'description': _('Regular expression pattern for matching Part IPN'),
@@ -1570,7 +1537,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'Minimum number of decimal places to display when rendering pricing data'
),
'default': 0,
'validator': [int, MinValueValidator(0), MaxValueValidator(4)],
'validator': [
int,
MinValueValidator(0),
MaxValueValidator(4),
common.validators.validate_decimal_places_min,
],
},
'PRICING_DECIMAL_PLACES': {
'name': _('Maximum Pricing Decimal Places'),
@@ -1578,7 +1550,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'Maximum number of decimal places to display when rendering pricing data'
),
'default': 6,
'validator': [int, MinValueValidator(2), MaxValueValidator(6)],
'validator': [
int,
MinValueValidator(2),
MaxValueValidator(6),
common.validators.validate_decimal_places_max,
],
},
'PRICING_USE_SUPPLIER_PRICING': {
'name': _('Use Supplier Pricing'),
@@ -1781,6 +1758,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': True,
'validator': bool,
},
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER': {
'name': _('Allow Out of Stock Transfer'),
'description': _(
'Allow stock items which are not in stock to be transferred between stock locations'
),
'default': False,
'validator': bool,
},
'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Pattern'),
'description': _(
@@ -1859,6 +1844,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'SALESORDER_SHIP_COMPLETE': {
'name': _('Mark Shipped Orders as Complete'),
'description': _(
'Sales orders marked as shipped will automatically be completed, bypassing the "shipped" status'
),
'default': False,
'validator': bool,
},
'PURCHASEORDER_REFERENCE_PATTERN': {
'name': _('Purchase Order Reference Pattern'),
'description': _(
@@ -1948,7 +1941,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'Restrict signup to certain domains (comma-separated, starting with @)'
),
'default': '',
'before_save': validate_email_domains,
'before_save': common.validators.validate_email_domains,
},
'SIGNUP_GROUP': {
'name': _('Group on signup'),
@@ -2544,82 +2537,6 @@ class PriceBreak(MetaMixin):
return converted.amount
def get_price(
instance,
quantity,
moq=True,
multiples=True,
currency=None,
break_name: str = 'price_breaks',
):
"""Calculate the price based on quantity price breaks.
- Don't forget to add in flat-fee cost (base_cost field)
- If MOQ (minimum order quantity) is required, bump quantity
- If order multiples are to be observed, then we need to calculate based on that, too
"""
from common.settings import currency_code_default
if hasattr(instance, break_name):
price_breaks = getattr(instance, break_name).all()
else:
price_breaks = []
# No price break information available?
if len(price_breaks) == 0:
return None
# Check if quantity is fraction and disable multiples
multiples = quantity % 1 == 0
# Order multiples
if multiples:
quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple)
pb_found = False
pb_quantity = -1
pb_cost = 0.0
if currency is None:
# Default currency selection
currency = currency_code_default()
pb_min = None
for pb in price_breaks:
# Store smallest price break
if not pb_min:
pb_min = pb
# Ignore this pricebreak (quantity is too high)
if pb.quantity > quantity:
continue
pb_found = True
# If this price-break quantity is the largest so far, use it!
if pb.quantity > pb_quantity:
pb_quantity = pb.quantity
# Convert everything to the selected currency
pb_cost = pb.convert_to(currency)
# Use smallest price break
if not pb_found and pb_min:
# Update price break information
pb_quantity = pb_min.quantity
pb_cost = pb_min.convert_to(currency)
# Trigger cost calculation using smallest price break
pb_found = True
# Convert quantity to decimal.Decimal format
quantity = decimal.Decimal(f'{quantity}')
if pb_found:
cost = pb_cost * quantity
return InvenTree.helpers.normalize(cost + instance.base_cost)
return None
class ColorTheme(models.Model):
"""Color Theme Setting."""
@@ -2630,14 +2547,14 @@ class ColorTheme(models.Model):
@classmethod
def get_color_themes_choices(cls):
"""Get all color themes from static folder."""
if not settings.STATIC_COLOR_THEMES_DIR.exists():
if not django_settings.STATIC_COLOR_THEMES_DIR.exists():
logger.error('Theme directory does not exist')
return []
# Get files list from css/color-themes/ folder
files_list = []
for file in settings.STATIC_COLOR_THEMES_DIR.iterdir():
for file in django_settings.STATIC_COLOR_THEMES_DIR.iterdir():
files_list.append([file.stem, file.suffix])
# Get color themes choices (CSS sheets)
@@ -2988,7 +2905,7 @@ class NotificationMessage(models.Model):
# Add timezone information if TZ is enabled (in production mode mostly)
delta = now() - (
self.creation.replace(tzinfo=timezone.utc)
if settings.USE_TZ
if django_settings.USE_TZ
else self.creation
)
return delta.seconds
@@ -3037,7 +2954,7 @@ def rename_notes_image(instance, filename):
class NotesImage(models.Model):
"""Model for storing uploading images for the 'notes' fields of various models.
Simply stores the image file, for use in the 'notes' field (of any models which support markdown)
Simply stores the image file, for use in the 'notes' field (of any models which support markdown).
"""
image = models.ImageField(
@@ -3048,6 +2965,21 @@ class NotesImage(models.Model):
date = models.DateTimeField(auto_now_add=True)
model_type = models.CharField(
max_length=100,
blank=True,
null=True,
validators=[common.validators.validate_notes_model_type],
help_text=_('Target model type for this image'),
)
model_id = models.IntegerField(
help_text=_('Target model ID for this image'),
blank=True,
null=True,
default=None,
)
class CustomUnit(models.Model):
"""Model for storing custom physical unit definitions.
@@ -3131,3 +3063,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)
+99 -1
View File
@@ -9,13 +9,18 @@ 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 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
@@ -125,6 +130,17 @@ class UserSettingsSerializer(SettingsSerializer):
user = serializers.PrimaryKeyRelatedField(read_only=True)
class CurrencyExchangeSerializer(serializers.Serializer):
"""Serializer for a Currency Exchange request.
It's only purpose is describing the results correctly in the API schema right now.
"""
base_currency = serializers.CharField(read_only=True)
exchange_rates = serializers.DictField(child=serializers.FloatField())
updated = serializers.DateTimeField(read_only=True)
class GenericReferencedSettingSerializer(SettingsSerializer):
"""Serializer for a GenericReferencedSetting model.
@@ -270,7 +286,7 @@ class NotesImageSerializer(InvenTreeModelSerializer):
"""Meta options for NotesImageSerializer."""
model = common_models.NotesImage
fields = ['pk', 'image', 'user', 'date']
fields = ['pk', 'image', 'user', 'date', 'model_type', 'model_id']
read_only_fields = ['date', 'user']
@@ -463,3 +479,85 @@ 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()
+30 -45
View File
@@ -1,60 +1,45 @@
"""User-configurable settings for the common app."""
import logging
from django.conf import settings
from django.core.cache import cache
from moneyed import CURRENCIES
logger = logging.getLogger('inventree')
def currency_code_default():
"""Returns the default currency code (or USD if not specified)."""
def get_global_setting(key, backup_value=None, **kwargs):
"""Return the value of a global setting using the provided key."""
from common.models import InvenTreeSetting
try:
cached_value = cache.get('currency_code_default', '')
except Exception:
cached_value = None
if backup_value is not None:
kwargs['backup_value'] = backup_value
if cached_value:
return cached_value
try:
code = InvenTreeSetting.get_setting(
'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True
)
except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here
code = ''
if code not in CURRENCIES:
code = 'USD' # pragma: no cover
# Cache the value for a short amount of time
try:
cache.set('currency_code_default', code, 30)
except Exception:
pass
return code
return InvenTreeSetting.get_setting(key, **kwargs)
def all_currency_codes():
"""Returns a list of all currency codes."""
return [(a, CURRENCIES[a].name) for a in CURRENCIES]
def set_global_setting(key, value, change_user=None, create=True, **kwargs):
"""Set the value of a global setting using the provided key."""
from common.models import InvenTreeSetting
kwargs['change_user'] = change_user
kwargs['create'] = create
return InvenTreeSetting.set_setting(key, value, **kwargs)
def currency_code_mappings():
"""Returns the current currency choices."""
return [(a, CURRENCIES[a].name) for a in settings.CURRENCIES]
def get_user_setting(key, user, backup_value=None, **kwargs):
"""Return the value of a user-specific setting using the provided key."""
from common.models import InvenTreeUserSetting
kwargs['user'] = user
if backup_value is not None:
kwargs['backup_value'] = backup_value
return InvenTreeUserSetting.get_setting(key, **kwargs)
def currency_codes():
"""Returns the current currency codes."""
return list(settings.CURRENCIES)
def set_user_setting(key, value, user, **kwargs):
"""Set the value of a user-specific setting using the provided key."""
from common.models import InvenTreeUserSetting
kwargs['user'] = user
return InvenTreeUserSetting.set_setting(key, value, **kwargs)
def stock_expiry_enabled():
+1 -1
View File
@@ -55,7 +55,7 @@ def update_news_feed():
# Fetch and parse feed
try:
feed = requests.get(settings.INVENTREE_NEWS_URL)
feed = requests.get(settings.INVENTREE_NEWS_URL, timeout=30)
d = feedparser.parse(feed.content)
except Exception: # pragma: no cover
logger.warning('update_news_feed: Error parsing the newsfeed')
@@ -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."""
+168 -17
View File
@@ -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,13 +20,16 @@ from django.urls import reverse
import PIL
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,
@@ -40,6 +45,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."""
@@ -50,7 +180,7 @@ class SettingsTest(InvenTreeTestCase):
# There should be two settings objects in the database
settings = InvenTreeSetting.objects.all()
self.assertTrue(settings.count() >= 2)
self.assertGreaterEqual(settings.count(), 2)
instance_name = InvenTreeSetting.objects.get(pk=1)
self.assertEqual(instance_name.key, 'INVENTREE_INSTANCE')
@@ -207,7 +337,7 @@ class SettingsTest(InvenTreeTestCase):
- Ensure that every setting key is valid
- Ensure that a validator is supplied
"""
self.assertTrue(type(setting) is dict)
self.assertIs(type(setting), dict)
name = setting.get('name', None)
@@ -273,13 +403,19 @@ class SettingsTest(InvenTreeTestCase):
print(f"run_settings_check failed for user setting '{key}'")
raise exc
@override_settings(SITE_URL=None)
@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)
InvenTreeSetting.set_setting(key, value, self.user)
try:
InvenTreeSetting.set_setting(key, value, change_user=self.user)
except Exception as exc:
print(f"test_defaults: Failed to set default value for setting '{key}'")
raise exc
self.assertEqual(value, InvenTreeSetting.get_setting(key))
@@ -287,11 +423,6 @@ class SettingsTest(InvenTreeTestCase):
setting = InvenTreeSetting.get_setting_object(key)
if setting.is_bool():
if setting.default_value in ['', None]:
raise ValueError(
f'Default value for boolean setting {key} not provided'
) # pragma: no cover
if setting.default_value not in [True, False]:
raise ValueError(
f'Non-boolean default value specified for {key}'
@@ -372,6 +503,30 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
# Number of results should match the number of settings
self.assertEqual(len(response.data), n_public_settings)
def test_currency_settings(self):
"""Run tests for currency specific settings."""
url = reverse('api-global-setting-detail', kwargs={'key': 'CURRENCY_CODES'})
response = self.patch(url, data={'value': 'USD,XYZ'}, expected_code=400)
self.assertIn("Invalid currency code: 'XYZ'", str(response.data))
response = self.patch(
url, data={'value': 'AUD,USD, AUD,AUD,'}, expected_code=400
)
self.assertIn("Duplicate currency code: 'AUD'", str(response.data))
response = self.patch(url, data={'value': ',,,,,'}, expected_code=400)
self.assertIn('No valid currency codes provided', str(response.data))
response = self.patch(url, data={'value': 'AUD,USD,GBP'}, expected_code=200)
codes = InvenTreeSetting.get_setting('CURRENCY_CODES')
self.assertEqual(codes, 'AUD,USD,GBP')
def test_company_name(self):
"""Test a settings object lifecycle e2e."""
setting = InvenTreeSetting.get_setting_object('INVENTREE_COMPANY_NAME')
@@ -726,7 +881,7 @@ class TaskListApiTests(InvenTreeAPITestCase):
response = self.get(url, expected_code=200)
for task in response.data:
self.assertTrue(task['name'] == 'time.sleep')
self.assertEqual(task['name'], 'time.sleep')
class WebhookMessageTests(TestCase):
@@ -951,17 +1106,13 @@ class CommonTest(InvenTreeAPITestCase):
from plugin import registry
# set flag true
common.models.InvenTreeSetting.set_setting(
'SERVER_RESTART_REQUIRED', True, None
)
set_global_setting('SERVER_RESTART_REQUIRED', True, None)
# reload the app
registry.reload_plugins()
# now it should be false again
self.assertFalse(
common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED')
)
self.assertFalse(get_global_setting('SERVER_RESTART_REQUIRED'))
def test_config_api(self):
"""Test config URLs."""
@@ -1093,7 +1244,7 @@ class CurrencyAPITests(InvenTreeAPITestCase):
# Updating via the external exchange may not work every time
for _idx in range(5):
self.post(reverse('api-currency-refresh'))
self.post(reverse('api-currency-refresh'), expected_code=200)
# There should be some new exchange rate objects now
if Rate.objects.all().exists():
+105
View File
@@ -0,0 +1,105 @@
"""Validation helpers for common models."""
import re
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
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(f'Model type does not support attachments')
def validate_notes_model_type(value):
"""Ensure that the provided model type is valid.
The provided value must map to a model which implements the 'InvenTreeNotesMixin'.
"""
import InvenTree.helpers_model
import InvenTree.models
if not value:
# Empty values are allowed
return
model_types = list(
InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNotesMixin)
)
model_names = [model.__name__.lower() for model in model_types]
if value.lower() not in model_names:
raise ValidationError(f"Invalid model type '{value}'")
def validate_decimal_places_min(value):
"""Validator for PRICING_DECIMAL_PLACES_MIN setting."""
try:
value = int(value)
places_max = int(get_global_setting('PRICING_DECIMAL_PLACES', create=False))
except Exception:
return
if value > places_max:
raise ValidationError(_('Minimum places cannot be greater than maximum places'))
def validate_decimal_places_max(value):
"""Validator for PRICING_DECIMAL_PLACES_MAX setting."""
try:
value = int(value)
places_min = int(get_global_setting('PRICING_DECIMAL_PLACES_MIN', create=False))
except Exception:
return
if value < places_min:
raise ValidationError(_('Maximum places cannot be less than minimum places'))
def validate_email_domains(setting):
"""Validate the email domains setting."""
if not setting.value:
return
domains = setting.value.split(',')
for domain in domains:
if not domain:
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}'))
+4 -10
View File
@@ -14,7 +14,6 @@ from .models import (
Company,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
@@ -120,15 +119,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."""
@@ -213,6 +203,8 @@ class AddressAdmin(ImportExportModelAdmin):
search_fields = ['company', 'country', 'postal_code']
autocomplete_fields = ['company']
class ContactResource(InvenTreeResource):
"""Class for managing Contact data import/export."""
@@ -237,3 +229,5 @@ class ContactAdmin(ImportExportModelAdmin):
list_display = ('company', 'name', 'role', 'email', 'phone')
search_fields = ['company', 'name', 'email']
autocomplete_fields = ['company']
+1 -66
View File
@@ -7,7 +7,7 @@ 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.api import ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
ORDER_FILTER,
SEARCH_ORDER_FILTER,
@@ -19,20 +19,16 @@ 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,
@@ -88,22 +84,6 @@ 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):
"""API endpoint for list view of Company model."""
@@ -227,22 +207,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."""
@@ -509,22 +473,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 +559,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',
@@ -60,6 +63,7 @@ class Migration(migrations.Migration):
],
options={
'db_table': 'part_supplierpart',
'verbose_name': 'Supplier Part',
},
),
migrations.CreateModel(
@@ -12,6 +12,6 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='company',
options={'ordering': ['name']},
options={'ordering': ['name'], 'verbose_name': 'Company'},
),
]
@@ -2,6 +2,7 @@
from django.db import migrations, connection
import djmoney.models.fields
import common.currency
import common.settings
@@ -16,11 +17,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='supplierpricebreak',
name='price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.settings.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
),
migrations.AddField(
model_name='supplierpricebreak',
name='price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.settings.currency_code_mappings(), default=common.settings.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3),
),
]
@@ -22,6 +22,7 @@ class Migration(migrations.Migration):
],
options={
'unique_together': {('part', 'manufacturer', 'MPN')},
'verbose_name': 'Manufacturer Part',
},
),
]
@@ -1,6 +1,7 @@
# Generated by Django 3.2.4 on 2021-07-02 13:21
import InvenTree.validators
import common.currency
import common.settings
from django.db import migrations, models
@@ -15,6 +16,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='company',
name='currency',
field=models.CharField(blank=True, default=common.settings.currency_code_default, help_text='Default currency used for this company', max_length=3, validators=[InvenTree.validators.validate_currency_code], verbose_name='Currency'),
field=models.CharField(blank=True, default=common.currency.currency_code_default, help_text='Default currency used for this company', max_length=3, validators=[InvenTree.validators.validate_currency_code], verbose_name='Currency'),
),
]
@@ -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')),
@@ -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',
),
]
+18 -48
View File
@@ -1,7 +1,6 @@
"""Company database model definitions."""
import os
from datetime import datetime
from decimal import Decimal
from django.apps import apps
@@ -20,6 +19,7 @@ from moneyed import CURRENCIES
from stdimage.models import StdImageField
from taggit.managers import TaggableManager
import common.currency
import common.models
import common.settings
import InvenTree.conversion
@@ -29,9 +29,9 @@ import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
from common.settings import currency_code_default
from common.currency import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.status_codes import PurchaseOrderStatusGroups
from order.status_codes import PurchaseOrderStatusGroups
def rename_company_image(instance, filename):
@@ -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():
@@ -212,7 +215,7 @@ class Company(
code = self.currency
if code not in CURRENCIES:
code = common.settings.currency_code_default()
code = common.currency.currency_code_default()
return code
@@ -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.
@@ -460,7 +443,9 @@ class Address(InvenTree.models.InvenTreeModel):
class ManufacturerPart(
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
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 +460,7 @@ class ManufacturerPart(
class Meta:
"""Metaclass defines extra model options."""
verbose_name = _('Manufacturer Part')
unique_together = ('part', 'manufacturer', 'MPN')
@staticmethod
@@ -513,6 +499,7 @@ class ManufacturerPart(
null=True,
verbose_name=_('Link'),
help_text=_('URL for external manufacturer part link'),
max_length=2000,
)
description = models.CharField(
@@ -562,26 +549,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.
@@ -678,6 +645,8 @@ class SupplierPart(
unique_together = ('part', 'supplier', 'SKU')
verbose_name = _('Supplier Part')
# This model was moved from the 'Part' app
db_table = 'part_supplierpart'
@@ -829,6 +798,7 @@ class SupplierPart(
null=True,
verbose_name=_('Link'),
help_text=_('URL for external supplier part link'),
max_length=2000,
)
description = models.CharField(
@@ -965,7 +935,7 @@ class SupplierPart(
SupplierPriceBreak.objects.create(part=self, quantity=quantity, price=price)
get_price = common.models.get_price
get_price = common.currency.get_price
def open_orders(self):
"""Return a database query for PurchaseOrder line items for this SupplierPart, limited to purchase orders that are open / outstanding."""
+2 -26
View File
@@ -11,13 +11,13 @@ from taggit.serializers import TagListSerializerField
import part.filters
from InvenTree.serializers import (
InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTagModelSerializer,
NotesFieldMixin,
RemoteImageMixin,
)
from part.serializers import PartBriefSerializer
@@ -25,10 +25,8 @@ from part.serializers import PartBriefSerializer
from .models import (
Address,
Company,
CompanyAttachment,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
@@ -102,7 +100,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
]
class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for Company object (full detail)."""
class Meta:
@@ -185,17 +183,6 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
return self.instance
class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the CompanyAttachment class."""
class Meta:
"""Metaclass defines serializer options."""
model = CompanyAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields(['company'])
class ContactSerializer(InvenTreeModelSerializer):
"""Serializer class for the Contact model."""
@@ -259,17 +246,6 @@ 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):
"""Serializer for the ManufacturerPartParameter model."""
@@ -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
@@ -305,6 +295,8 @@
'{% url "api-company-detail" company.pk %}',
{
editable: true,
model_type: "company",
model_id: {{ company.pk }},
}
);
});
@@ -177,17 +177,7 @@ src="{% static 'img/blank_image.png' %}"
{{ 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
}
}
});
loadAttachmentTable('manufacturerpart', {{ part.pk }});
});
$('#parameter-create').click(function() {
+1 -1
View File
@@ -130,7 +130,7 @@ class CompanyTest(InvenTreeAPITestCase):
expected_code=400,
)
self.assertTrue('currency' in response.data)
self.assertIn('currency', response.data)
def test_company_active(self):
"""Test that the 'active' value and filter works."""
@@ -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(
+5 -1
View File
@@ -286,7 +286,11 @@ class ManufacturerPartSimpleTest(TestCase):
def test_delete(self):
"""Test deletion of a ManufacturerPart."""
Part.objects.get(pk=self.part.id).delete()
part = Part.objects.get(pk=self.part.id)
part.active = False
part.save()
part.delete()
# Check that ManufacturerPart was deleted
self.assertEqual(ManufacturerPart.objects.count(), 3)
+34 -134
View File
@@ -1,3 +1,6 @@
# InvenTree Configuration Template
# Ref: https://docs.inventree.org/en/stable/start/config/
# Note: Environment variables take precedence over values set in this file
# Secret key for backend
# Use the environment variable INVENTREE_SECRET_KEY_FILE
@@ -5,16 +8,10 @@
# Database backend selection - Configure backend database settings
# Documentation: https://docs.inventree.org/en/latest/start/config/
# 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
database:
# Uncomment (and edit) one of the database configurations below,
# or specify database options using environment variables
# Refer to the django documentation for full list of options
# --- Available options: ---
# ENGINE: Database engine. Selection from:
# - mysql
@@ -26,69 +23,30 @@ database:
# HOST: Database host address (if required)
# PORT: Database host port (if required)
# --- Database settings ---
#ENGINE: sampleengine
#NAME: '/path/to/database'
#USER: sampleuser
#PASSWORD: samplepassword
#HOST: samplehost
#PORT: 123456
# --- Example Configuration - MySQL ---
#ENGINE: mysql
#NAME: inventree
#USER: inventree
#PASSWORD: inventree_password
#HOST: 'localhost'
#PORT: '3306'
# --- Example Configuration - Postgresql ---
#ENGINE: postgresql
#NAME: inventree
#USER: inventree
#PASSWORD: inventree_password
#HOST: 'localhost'
#PORT: '5432'
# --- Example Configuration - sqlite3 ---
# ENGINE: sqlite3
# NAME: '/home/inventree/database.sqlite3'
# Set debug to False to run in production mode
# Use the environment variable INVENTREE_DEBUG
# 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 interface (default = True)
# Or, use the environment variable INVENTREE_ADMIN_ENABLED
# Set to False to disable the admin interfac, or use the environment variable INVENTREE_ADMIN_ENABLED
#admin_enabled: True
# Set the admin URL (default is 'admin')
# Or, use the environment variable INVENTREE_ADMIN_URL
# Set the admin URL, or use the environment variable INVENTREE_ADMIN_URL
#admin_url: 'admin'
# Configure the system logging level
# Use environment variable INVENTREE_LOG_LEVEL
# Configure the system logging level (or use environment variable INVENTREE_LOG_LEVEL)
# Options: DEBUG / INFO / WARNING / ERROR / CRITICAL
log_level: WARNING
# Enable database-level logging
# Use the environment variable INVENTREE_DB_LOGGING
# Enable database-level logging, or use the environment variable INVENTREE_DB_LOGGING
db_logging: False
# Select default system language (default is 'en-us')
# Use the environment variable INVENTREE_LANGUAGE
# Select default system language , or use the environment variable INVENTREE_LANGUAGE
language: en-us
# System time-zone (default is UTC)
# Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# System time-zone (default is UTC). Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
timezone: UTC
# Base URL for the InvenTree server
# Use the environment variable INVENTREE_SITE_URL
# site_url: 'http://localhost:8000'
# Base currency code (or use env var INVENTREE_BASE_CURRENCY)
base_currency: USD
# Base URL for the InvenTree server (or use the environment variable INVENTREE_SITE_URL)
site_url: 'http://localhost:8000'
# Add new user on first startup by either adding values here or from a file
#admin_user: admin
@@ -96,17 +54,6 @@ base_currency: USD
#admin_password: inventree
#admin_password_file: '/etc/inventree/admin_password.txt'
# List of currencies supported by default. Add other currencies here to allow use in InvenTree
currencies:
- AUD
- CAD
- CNY
- EUR
- GBP
- JPY
- NZD
- USD
# Email backend configuration
# Ref: https://docs.djangoproject.com/en/dev/topics/email/
# Alternatively, these options can all be set using environment variables,
@@ -130,34 +77,18 @@ sentry_enabled: False
#sentry_sample_rate: 0.1
#sentry_dsn: https://custom@custom.ingest.sentry.io/custom
# OpenTelemetry tracing/metrics - disabled by default
# OpenTelemetry tracing/metrics - disabled by default - refer to the documentation for full list of options
# This can be used to send tracing data, logs and metrics to OpenTelemtry compatible backends
# See https://opentelemetry.io/ecosystem/vendors/ for a list of supported backends
# Alternatively, use environment variables eg. INVENTREE_TRACING_ENABLED, INVENTREE_TRACING_HEADERS, INVENTREE_TRACING_AUTH
#tracing:
# enabled: true
# endpoint: https://otlp-gateway-prod-eu-west-0.grafana.net/otlp
# headers:
# api-key: 'sample'
# auth:
# basic:
# username: '******'
# password: 'glc_****'
# is_http: true
# append_http: true
# console: false
# resources:
# CUSTOM_KEY: 'CUSTOM_VALUE'
tracing:
enabled: false
# Set this variable to True to enable InvenTree Plugins
# Alternatively, use the environment variable INVENTREE_PLUGINS_ENABLED
# Set this variable to True to enable InvenTree Plugins, or use the environment variable INVENTREE_PLUGINS_ENABLED
plugins_enabled: False
#plugin_noinstall: True
#plugin_file: '/path/to/plugins.txt'
#plugin_dir: '/path/to/plugins/'
# Set this variable to True to enable auto-migrations
# Alternatively, use the environment variable INVENTREE_AUTO_UPDATE
# Set this variable to True to enable auto-migrations, or use the environment variable INVENTREE_AUTO_UPDATE
auto_update: False
# Allowed hosts (see ALLOWED_HOSTS in Django settings documentation)
@@ -181,6 +112,11 @@ use_x_forwarded_host: false
# Override with the environment variable INVENTREE_USE_X_FORWARDED_PORT
use_x_forwarded_port: false
# Cookie settings
cookie:
secure: false
samesite: none
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/adamchainz/django-cors-headers)
cors:
allow_all: true
@@ -207,6 +143,13 @@ background:
timeout: 90
max_attempts: 5
# External cache configuration (refer to the documentation for full list of options)
cache:
enabled: false
host: 'inventree-cache'
port: 6379
# Login configuration
login_confirm_days: 3
login_attempts: 5
@@ -243,56 +186,13 @@ remote_login_header: HTTP_REMOTE_USER
# github:
# VERIFIED_EMAIL: true
# Add LDAP support
# ldap:
# enabled: false
# debug: false # enable debug mode to troubleshoot ldap configuration
# server_uri: ldaps://example.org
# bind_dn: cn=admin,dc=example,dc=org
# bind_password: admin_password
# search_base_dn: cn=Users,dc=example,dc=org
# # enable TLS encryption over the standard LDAP port,
# # see: https://django-auth-ldap.readthedocs.io/en/latest/reference.html#auth-ldap-start-tls
# # start_tls: false
# # uncomment if you want to use direct bind, bind_dn and bin_password is not necessary then
# # user_dn_template: "uid=%(user)s,dc=example,dc=org"
# # uncomment to set advanced global options, see https://www.python-ldap.org/en/latest/reference/ldap.html#ldap-options
# # for all available options (keys and values starting with OPT_ get automatically converted to python-ldap keys)
# # global_options:
# # OPT_X_TLS_REQUIRE_CERT: OPT_X_TLS_NEVER
# # OPT_X_TLS_CACERTFILE: /opt/inventree/ldapca.pem
# # uncomment for advanced filter search, default: uid=%(user)s
# # search_filter_str:
# # uncomment for advanced user attribute mapping (in the format <InvenTree attribute>: <LDAP attribute>)
# # user_attr_map:
# # first_name: givenName
# # last_name: sn
# # email: mail
# # always update the user on each login, default: true
# # always_update_user: true
# # cache timeout to reduce traffic with LDAP server, default: 3600 (1h)
# # cache_timeout: 3600
# # LDAP group support
# # group_search: ou=groups,dc=example,dc=com
# # require_group: cn=inventree_allow,ou=groups,dc=example,dc=com
# # deny_group: cn=inventree_deny,ou=groups,dc=example,dc=com
# # Set staff/superuser flag based on LDAP group membership
# # user_flags_by_group:
# # is_staff: cn=inventree_staff,ou=groups,dc=example,dc=com
# # is_superuser: cn=inventree_superuser,ou=groups,dc=example,dc=com
# Add LDAP support (refer to the documentation for available options)
# Ref: https://docs.inventree.org/en/stable/start/advanced/#ldap
ldap:
enabled: false
# Customization options
# Add custom messages to the login page or main interface navbar or exchange the logo
# Use environment variable INVENTREE_CUSTOMIZE or INVENTREE_CUSTOM_LOGO
# Logo and splash paths and filenames must be relative to the static_root directory
# Ref: https://docs.inventree.org/en/stable/start/config/#customization-options
# customize:
# login_message: InvenTree demo instance - <a href='https://inventree.org/demo.html'> Click here for login details</a>
# navbar_message: <h6>InvenTree demo mode <a href='https://inventree.org/demo.html'><span class='fas fa-info-circle'></span></a></h6>
@@ -1,134 +0,0 @@
"""Shared templating code."""
import logging
import warnings
from pathlib import Path
from django.core.exceptions import AppRegistryNotReady
from django.core.files.storage import default_storage
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
import InvenTree.helpers
from InvenTree.config import ensure_dir
logger = logging.getLogger('inventree')
class TemplatingMixin:
"""Mixin that contains shared templating code."""
name: str = ''
db: str = ''
def __init__(self, *args, **kwargs):
"""Ensure that the required properties are set."""
super().__init__(*args, **kwargs)
if self.name == '':
raise NotImplementedError('ref must be set')
if self.db == '':
raise NotImplementedError('db must be set')
def create_defaults(self):
"""Function that creates all default templates for the app."""
raise NotImplementedError('create_defaults must be implemented')
def get_src_dir(self, ref_name):
"""Get the source directory for the default templates."""
raise NotImplementedError('get_src_dir must be implemented')
def get_new_obj_data(self, data, filename):
"""Get the data for a new template db object."""
raise NotImplementedError('get_new_obj_data must be implemented')
# Standardized code
def ready(self):
"""This function is called whenever the app is loaded."""
import InvenTree.ready
# skip loading if plugin registry is not loaded or we run in a background thread
if (
not InvenTree.ready.isPluginRegistryLoaded()
or not InvenTree.ready.isInMainThread()
):
return
if not InvenTree.ready.canAppAccessDatabase(allow_test=False):
return # pragma: no cover
with maintenance_mode_on():
try:
self.create_defaults()
except (
AppRegistryNotReady,
IntegrityError,
OperationalError,
ProgrammingError,
):
# Database might not yet be ready
warnings.warn(
f'Database was not ready for creating {self.name}s', stacklevel=2
)
set_maintenance_mode(False)
def create_template_dir(self, model, data):
"""Create folder and database entries for the default templates, if they do not already exist."""
ref_name = model.getSubdir()
# Create root dir for templates
src_dir = self.get_src_dir(ref_name)
ensure_dir(Path(self.name, 'inventree', ref_name), default_storage)
# Copy each template across (if required)
for entry in data:
self.create_template_file(model, src_dir, entry, ref_name)
def create_template_file(self, model, src_dir, data, ref_name):
"""Ensure a label template is in place."""
# Destination filename
filename = Path(self.name, 'inventree', ref_name, data['file'])
src_file = src_dir.joinpath(data['file'])
do_copy = False
if not default_storage.exists(filename):
logger.info("%s template '%s' is not present", self.name, filename)
do_copy = True
else:
# Check if the file contents are different
src_hash = InvenTree.helpers.hash_file(src_file)
dst_hash = InvenTree.helpers.hash_file(filename, default_storage)
if src_hash != dst_hash:
logger.info("Hash differs for '%s'", filename)
do_copy = True
if do_copy:
logger.info("Copying %s template '%s'", self.name, filename)
# Ensure destination dir exists
ensure_dir(filename.parent, default_storage)
# Copy file
default_storage.save(filename, src_file.open('rb'))
# Check if a file matching the template already exists
try:
if model.objects.filter(**{self.db: filename}).exists():
return # pragma: no cover
except Exception:
logger.exception(
"Failed to query %s for '%s' - you should run 'invoke update' first!",
self.name,
filename,
)
logger.info("Creating entry for %s '%s'", model, data.get('name'))
try:
model.objects.create(**self.get_new_obj_data(data, str(filename)))
except Exception as _e:
logger.warning(
"Failed to create %s '%s' with error '%s'", self.name, data['name'], _e
)
-17
View File
@@ -1,17 +0,0 @@
"""Admin functionality for the 'label' app."""
from django.contrib import admin
import label.models
class LabelAdmin(admin.ModelAdmin):
"""Admin class for the various label models."""
list_display = ('name', 'description', 'label', 'filters', 'enabled')
admin.site.register(label.models.StockItemLabel, LabelAdmin)
admin.site.register(label.models.StockLocationLabel, LabelAdmin)
admin.site.register(label.models.PartLabel, LabelAdmin)
admin.site.register(label.models.BuildLineLabel, LabelAdmin)
-504
View File
@@ -1,504 +0,0 @@
"""API functionality for the 'label' app."""
from django.core.exceptions import FieldError, ValidationError
from django.http import JsonResponse
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_page, never_cache
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import serializers
from rest_framework.exceptions import NotFound
from rest_framework.request import clone_request
import build.models
import common.models
import InvenTree.exceptions
import InvenTree.helpers
import label.models
import label.serializers
from InvenTree.api import MetadataView
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListCreateAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from part.models import Part
from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
from plugin.registry import registry
from stock.models import StockItem, StockLocation
class LabelFilterMixin:
"""Mixin for filtering a queryset by a list of object ID values.
Each implementing class defines a database model to lookup,
and a "key" (query parameter) for providing a list of ID (PK) values.
This mixin defines a 'get_items' method which provides a generic
implementation to return a list of matching database model instances.
"""
# Database model for instances to actually be "printed" against this label template
ITEM_MODEL = None
# Default key for looking up database model instances
ITEM_KEY = 'item'
def get_items(self):
"""Return a list of database objects from query parameter."""
ids = []
# Construct a list of possible query parameter value options
# e.g. if self.ITEM_KEY = 'part' -> ['part', 'part[]', 'parts', parts[]']
for k in [self.ITEM_KEY + x for x in ['', '[]', 's', 's[]']]:
if ids := self.request.query_params.getlist(k, []):
# Return the first list of matches
break
# Next we must validate each provided object ID
valid_ids = []
for id in ids:
try:
valid_ids.append(int(id))
except ValueError:
pass
# Filter queryset by matching ID values
return self.ITEM_MODEL.objects.filter(pk__in=valid_ids)
class LabelListView(LabelFilterMixin, ListCreateAPI):
"""Generic API class for label templates."""
def filter_queryset(self, queryset):
"""Filter the queryset based on the provided label ID values.
As each 'label' instance may optionally define its own filters,
the resulting queryset is the 'union' of the two.
"""
queryset = super().filter_queryset(queryset)
items = self.get_items()
if len(items) > 0:
"""
At this point, we are basically forced to be inefficient,
as we need to compare the 'filters' string of each label,
and see if it matches against each of the requested items.
TODO: In the future, if this becomes excessively slow, it
will need to be readdressed.
"""
valid_label_ids = set()
for lbl in queryset.all():
matches = True
try:
filters = InvenTree.helpers.validateFilterString(lbl.filters)
except ValidationError:
continue
for item in items:
item_query = self.ITEM_MODEL.objects.filter(pk=item.pk)
try:
if not item_query.filter(**filters).exists():
matches = False
break
except FieldError:
matches = False
break
# Matched all items
if matches:
valid_label_ids.add(lbl.pk)
else:
continue
# Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=list(valid_label_ids))
return queryset
filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter]
filterset_fields = ['enabled']
search_fields = ['name', 'description']
@method_decorator(cache_page(5), name='dispatch')
class LabelPrintMixin(LabelFilterMixin):
"""Mixin for printing labels."""
rolemap = {'GET': 'view', 'POST': 'view'}
def check_permissions(self, request):
"""Override request method to GET so that also non superusers can print using a post request."""
if request.method == 'POST':
request = clone_request(request, 'GET')
return super().check_permissions(request)
@method_decorator(never_cache)
def dispatch(self, *args, **kwargs):
"""Prevent caching when printing report templates."""
return super().dispatch(*args, **kwargs)
def get_serializer(self, *args, **kwargs):
"""Define a get_serializer method to be discoverable by the OPTIONS request."""
# Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(self.request)
kwargs.setdefault('context', self.get_serializer_context())
serializer = plugin.get_printing_options_serializer(
self.request, *args, **kwargs
)
# if no serializer is defined, return an empty serializer
if not serializer:
return serializers.Serializer()
return serializer
def get(self, request, *args, **kwargs):
"""Perform a GET request against this endpoint to print labels."""
common.models.InvenTreeUserSetting.set_setting(
'DEFAULT_' + self.ITEM_KEY.upper() + '_LABEL_TEMPLATE',
self.get_object().pk,
None,
user=request.user,
)
return self.print(request, self.get_items())
def post(self, request, *args, **kwargs):
"""Perform a GET request against this endpoint to print labels."""
return self.get(request, *args, **kwargs)
def get_plugin(self, request):
"""Return the label printing plugin associated with this request.
This is provided in the url, e.g. ?plugin=myprinter
Requires:
- settings.PLUGINS_ENABLED is True
- matching plugin can be found
- matching plugin implements the 'labels' mixin
- matching plugin is enabled
"""
plugin_key = request.query_params.get('plugin', None)
# No plugin provided!
if plugin_key is None:
# Default to the builtin label printing plugin
plugin_key = InvenTreeLabelPlugin.NAME.lower()
plugin = registry.get_plugin(plugin_key)
if not plugin:
raise NotFound(f"Plugin '{plugin_key}' not found")
if not plugin.is_active():
raise ValidationError(f"Plugin '{plugin_key}' is not enabled")
if not plugin.mixin_enabled('labels'):
raise ValidationError(
f"Plugin '{plugin_key}' is not a label printing plugin"
)
# Only return the plugin if it is enabled and has the label printing mixin
return plugin
def print(self, request, items_to_print):
"""Print this label template against a number of pre-validated items."""
# Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(request)
if len(items_to_print) == 0:
# No valid items provided, return an error message
raise ValidationError('No valid objects provided to label template')
# Label template
label = self.get_object()
# Check the label dimensions
if label.width <= 0 or label.height <= 0:
raise ValidationError('Label has invalid dimensions')
# if the plugin returns a serializer, validate the data
if serializer := plugin.get_printing_options_serializer(
request, data=request.data, context=self.get_serializer_context()
):
serializer.is_valid(raise_exception=True)
# At this point, we offload the label(s) to the selected plugin.
# The plugin is responsible for handling the request and returning a response.
try:
result = plugin.print_labels(
label,
items_to_print,
request,
printing_options=(serializer.data if serializer else {}),
)
except ValidationError as e:
raise (e)
except Exception as e:
raise ValidationError([_('Error printing label'), str(e)])
if isinstance(result, JsonResponse):
result['plugin'] = plugin.plugin_slug()
return result
raise ValidationError(
f"Plugin '{plugin.plugin_slug()}' returned invalid response type '{type(result)}'"
)
class StockItemLabelMixin:
"""Mixin for StockItemLabel endpoints."""
queryset = label.models.StockItemLabel.objects.all()
serializer_class = label.serializers.StockItemLabelSerializer
ITEM_MODEL = StockItem
ITEM_KEY = 'item'
class StockItemLabelList(StockItemLabelMixin, LabelListView):
"""API endpoint for viewing list of StockItemLabel objects.
Filterable by:
- enabled: Filter by enabled / disabled status
- item: Filter by single stock item
- items: Filter by list of stock items
"""
pass
class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockItemLabel object."""
pass
class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockItemLabel object."""
pass
class StockLocationLabelMixin:
"""Mixin for StockLocationLabel endpoints."""
queryset = label.models.StockLocationLabel.objects.all()
serializer_class = label.serializers.StockLocationLabelSerializer
ITEM_MODEL = StockLocation
ITEM_KEY = 'location'
class StockLocationLabelList(StockLocationLabelMixin, LabelListView):
"""API endpoint for viewiing list of StockLocationLabel objects.
Filterable by:
- enabled: Filter by enabled / disabled status
- location: Filter by a single stock location
- locations: Filter by list of stock locations
"""
pass
class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockLocationLabel object."""
pass
class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockLocationLabel object."""
pass
class PartLabelMixin:
"""Mixin for PartLabel endpoints."""
queryset = label.models.PartLabel.objects.all()
serializer_class = label.serializers.PartLabelSerializer
ITEM_MODEL = Part
ITEM_KEY = 'part'
class PartLabelList(PartLabelMixin, LabelListView):
"""API endpoint for viewing list of PartLabel objects."""
pass
class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single PartLabel object."""
pass
class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a PartLabel object."""
pass
class BuildLineLabelMixin:
"""Mixin class for BuildLineLabel endpoints."""
queryset = label.models.BuildLineLabel.objects.all()
serializer_class = label.serializers.BuildLineLabelSerializer
ITEM_MODEL = build.models.BuildLine
ITEM_KEY = 'line'
class BuildLineLabelList(BuildLineLabelMixin, LabelListView):
"""API endpoint for viewing a list of BuildLineLabel objects."""
pass
class BuildLineLabelDetail(BuildLineLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single BuildLineLabel object."""
pass
class BuildLineLabelPrint(BuildLineLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a BuildLineLabel object."""
pass
label_api_urls = [
# Stock item labels
path(
'stock/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
StockItemLabelPrint.as_view(),
name='api-stockitem-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.StockItemLabel},
name='api-stockitem-label-metadata',
),
path(
'',
StockItemLabelDetail.as_view(),
name='api-stockitem-label-detail',
),
]),
),
# List view
path('', StockItemLabelList.as_view(), name='api-stockitem-label-list'),
]),
),
# Stock location labels
path(
'location/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
StockLocationLabelPrint.as_view(),
name='api-stocklocation-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.StockLocationLabel},
name='api-stocklocation-label-metadata',
),
path(
'',
StockLocationLabelDetail.as_view(),
name='api-stocklocation-label-detail',
),
]),
),
# List view
path(
'',
StockLocationLabelList.as_view(),
name='api-stocklocation-label-list',
),
]),
),
# Part labels
path(
'part/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
PartLabelPrint.as_view(),
name='api-part-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.PartLabel},
name='api-part-label-metadata',
),
path('', PartLabelDetail.as_view(), name='api-part-label-detail'),
]),
),
# List view
path('', PartLabelList.as_view(), name='api-part-label-list'),
]),
),
# BuildLine labels
path(
'buildline/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
BuildLineLabelPrint.as_view(),
name='api-buildline-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.BuildLineLabel},
name='api-buildline-label-metadata',
),
path(
'',
BuildLineLabelDetail.as_view(),
name='api-buildline-label-detail',
),
]),
),
# List view
path('', BuildLineLabelList.as_view(), name='api-buildline-label-list'),
]),
),
]
-107
View File
@@ -1,107 +0,0 @@
"""Config options for the label app."""
from pathlib import Path
from django.apps import AppConfig
from generic.templating.apps import TemplatingMixin
class LabelConfig(TemplatingMixin, AppConfig):
"""Configuration class for the "label" app."""
name = 'label'
db = 'label'
def create_defaults(self):
"""Create all default templates."""
# Test if models are ready
try:
import label.models
except Exception: # pragma: no cover
# Database is not ready yet
return
assert bool(label.models.StockLocationLabel is not None)
# Create the categories
self.create_template_dir(
label.models.StockItemLabel,
[
{
'file': 'qr.html',
'name': 'QR Code',
'description': 'Simple QR code label',
'width': 24,
'height': 24,
}
],
)
self.create_template_dir(
label.models.StockLocationLabel,
[
{
'file': 'qr.html',
'name': 'QR Code',
'description': 'Simple QR code label',
'width': 24,
'height': 24,
},
{
'file': 'qr_and_text.html',
'name': 'QR and text',
'description': 'Label with QR code and name of location',
'width': 50,
'height': 24,
},
],
)
self.create_template_dir(
label.models.PartLabel,
[
{
'file': 'part_label.html',
'name': 'Part Label',
'description': 'Simple part label',
'width': 70,
'height': 24,
},
{
'file': 'part_label_code128.html',
'name': 'Barcode Part Label',
'description': 'Simple part label with Code128 barcode',
'width': 70,
'height': 24,
},
],
)
self.create_template_dir(
label.models.BuildLineLabel,
[
{
'file': 'buildline_label.html',
'name': 'Build Line Label',
'description': 'Example build line label',
'width': 125,
'height': 48,
}
],
)
def get_src_dir(self, ref_name):
"""Get the source directory."""
return Path(__file__).parent.joinpath('templates', self.name, ref_name)
def get_new_obj_data(self, data, filename):
"""Get the data for a new template db object."""
return {
'name': data['name'],
'description': data['description'],
'label': filename,
'filters': '',
'enabled': True,
'width': data['width'],
'height': data['height'],
}
@@ -1,30 +0,0 @@
# Generated by Django 3.0.7 on 2020-08-15 23:27
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='StockItemLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Label name', max_length=100, unique=True)),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True)),
('label', models.FileField(help_text='Label template file', upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])])),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString])),
],
options={
'abstract': False,
},
),
]
@@ -1,18 +0,0 @@
# Generated by Django 3.0.7 on 2020-08-22 23:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='enabled',
field=models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled'),
),
]
@@ -1,30 +0,0 @@
# Generated by Django 3.0.7 on 2021-01-08 12:06
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0002_stockitemlabel_enabled'),
]
operations = [
migrations.CreateModel(
name='StockLocationLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Label name', max_length=100, unique=True)),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True)),
('label', models.FileField(help_text='Label template file', upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])])),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString])),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
],
options={
'abstract': False,
},
),
]
@@ -1,56 +0,0 @@
# Generated by Django 3.0.7 on 2021-01-11 12:02
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0003_stocklocationlabel'),
]
operations = [
migrations.AlterField(
model_name='stockitemlabel',
name='description',
field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='label',
field=models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='name',
field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='description',
field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='label',
field=models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='name',
field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'),
),
]
@@ -1,24 +0,0 @@
# Generated by Django 3.0.7 on 2021-01-13 12:02
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0004_auto_20210111_2302'),
]
operations = [
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_location_filters], verbose_name='Filters'),
),
]
@@ -1,34 +0,0 @@
# Generated by Django 3.0.7 on 2021-02-22 04:35
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0005_auto_20210113_2302'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='height',
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
),
migrations.AddField(
model_name='stockitemlabel',
name='width',
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='height',
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='width',
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
),
]
@@ -1,23 +0,0 @@
# Generated by Django 3.2 on 2021-05-13 03:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0006_auto_20210222_1535'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='filename_pattern',
field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='filename_pattern',
field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'),
),
]

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