mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-08 08:01:38 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue6281
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)')
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
+25
@@ -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),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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."""
|
||||
@@ -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():
|
||||
|
||||
@@ -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}'))
|
||||
@@ -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']
|
||||
|
||||
@@ -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')),
|
||||
|
||||
+24
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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'),
|
||||
]),
|
||||
),
|
||||
]
|
||||
@@ -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
Reference in New Issue
Block a user