2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-13 20:10:49 +00:00

Merge branch 'master' into generic-parameters

This commit is contained in:
Oliver
2025-11-09 20:09:20 +11:00
committed by GitHub
173 changed files with 48254 additions and 39044 deletions
@@ -1,77 +0,0 @@
"""Middleware to require 2FA for users."""
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.utils.deprecation import MiddlewareMixin
from django.utils.translation import gettext_lazy as _
from allauth.account.authentication import get_authentication_records
from allauth.mfa.utils import is_mfa_enabled
from allauth.mfa.webauthn.internal.flows import did_use_passwordless_login
def is_multifactor_logged_in(request: HttpRequest) -> bool:
"""Check if the user is logged in with multifactor authentication."""
authns = get_authentication_records(request)
return is_mfa_enabled(request.user) and (
did_use_passwordless_login(request)
or any(record.get('method') == 'mfa' for record in authns)
)
class AllUserRequire2FAMiddleware(MiddlewareMixin):
"""Ensure that users have two-factor authentication enabled before they have access restricted endpoints.
Adapted from https://github.com/pennersr/django-allauth/issues/3649
"""
allowed_pages = [
'api-user-meta',
'api-user-me',
'api-user-roles',
'api-inventree-info',
'api-token',
# web platform urls
'password_reset_confirm',
'web',
'web-wildcard',
'web-assets',
]
app_names = ['headless']
require_2fa_message = _(
'You must enable two-factor authentication before doing anything else.'
)
def on_require_2fa(self, request: HttpRequest) -> HttpResponse:
"""Force user to mfa activation."""
return JsonResponse({'id': 'mfa_register'}, status=401)
def is_allowed_page(self, request: HttpRequest) -> bool:
"""Check if the current page can be accessed without mfa."""
match = request.resolver_match
return (
None
if match is None
else any(ref in self.app_names for ref in match.app_names)
or match.url_name in self.allowed_pages
or match.route == 'favicon.ico'
)
def enforce_2fa(self, request):
"""Check if 2fa should be enforced for this request."""
return True
def process_view(
self, request: HttpRequest, view_func, view_args, view_kwargs
) -> HttpResponse:
"""If set up enforce 2fa registration."""
if request.user.is_anonymous:
return None
if self.is_allowed_page(request):
return None
if is_multifactor_logged_in(request):
return None
if self.enforce_2fa(request):
return self.on_require_2fa(request)
return None
+2 -2
View File
@@ -95,8 +95,8 @@ class LicenseView(APIView):
@extend_schema(responses={200: OpenApiResponse(response=LicenseViewSerializer)})
def get(self, request, *args, **kwargs):
"""Return information about the InvenTree server."""
backend = Path(__file__).parent.joinpath('licenses.txt')
frontend = Path(__file__).parent.parent.joinpath(
backend = InvenTree.config.get_base_dir().joinpath('InvenTree', 'licenses.txt')
frontend = InvenTree.config.get_base_dir().joinpath(
'web/static/web/.vite/dependencies.json'
)
return JsonResponse({
+12 -1
View File
@@ -1,11 +1,22 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 420
INVENTREE_API_VERSION = 423
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v423 -> 2025-11-05 : https://github.com/inventree/InvenTree/pull/10772
- Adds "category_detail" field to BomItem API endpoints
- Adds "category_detail" field to BuildLine API endpoints
v422 -> 2025-11-03 : https://github.com/inventree/InvenTree/pull/10750
- Adds ability to search StockItem API by supplier SKU
- Adds ability to search StockItem API by manufacturer MPN
v421 -> 2025-10-31 : https://github.com/inventree/InvenTree/pull/10724
- Allow upload of attachments against SupplierPart objects via the API
v420 -> 2025-10-26 : https://github.com/inventree/InvenTree/pull/10675
- Adds optional "customer_detail" filter to SalesOrderShipment API endpoint
+14 -2
View File
@@ -321,6 +321,18 @@ class InvenTreeConfig(AppConfig):
return
if not InvenTree.tasks.check_for_migrations():
logger.error('INVE-W8: Database Migrations required')
sys.exit(1)
# Detect if this an empty database - if so, start with a fresh migration
if (
settings.DOCKER
and not InvenTree.ready.isInTestMode()
and not InvenTree.ready.isRunningMigrations()
and InvenTree.tasks.get_migration_count() == 0
):
logger.warning(
'INVE-W8: Empty database detected - trying to run migrations'
)
InvenTree.tasks.check_for_migrations(force_run=True)
else:
logger.error('INVE-W8: Database Migrations required')
sys.exit(1)
MIGRATIONS_CHECK_DONE = True
@@ -31,6 +31,7 @@ from PIL import Image
from common.currency import currency_code_default
from .setting.storages import StorageBackends
from .settings import MEDIA_URL, STATIC_URL
logger = structlog.get_logger('inventree')
@@ -176,6 +177,8 @@ def constructPathString(path: list[str], max_chars: int = 250) -> str:
def getMediaUrl(filename):
"""Return the qualified access path for the given file, under the media directory."""
if settings.STORAGE_TARGET == StorageBackends.S3:
return str(filename)
return os.path.join(MEDIA_URL, str(filename))
+76 -4
View File
@@ -5,17 +5,17 @@ from urllib.parse import urlsplit
from django.conf import settings
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
from django.http import HttpResponse
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect, render
from django.urls import resolve, reverse_lazy
from django.utils.deprecation import MiddlewareMixin
from django.utils.http import is_same_domain
from django.utils.translation import gettext_lazy as _
import structlog
from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting
from InvenTree.AllUserRequire2FAMiddleware import AllUserRequire2FAMiddleware
from InvenTree.cache import create_session_cache, delete_session_cache
from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller
from users.models import ApiToken
@@ -146,8 +146,80 @@ class AuthRequiredMiddleware:
return response
class Check2FAMiddleware(AllUserRequire2FAMiddleware):
"""Ensure that mfa is enforced if set so."""
class Check2FAMiddleware(MiddlewareMixin):
"""Ensure that users have two-factor authentication enabled before they have access restricted endpoints.
Adapted from https://github.com/pennersr/django-allauth/issues/3649
"""
allowed_pages = [
'api-user-meta',
'api-user-me',
'api-user-roles',
'api-inventree-info',
'api-token',
# web platform urls
'password_reset_confirm',
'index',
'web',
'web-wildcard',
'web-assets',
]
app_names = ['headless']
require_2fa_message = _(
'You must enable two-factor authentication before doing anything else.'
)
def on_require_2fa(self, request: HttpRequest) -> HttpResponse:
"""Force user to mfa activation."""
return JsonResponse(
{'id': 'mfa_register', 'error': self.require_2fa_message}, status=401
)
def is_allowed_page(self, request: HttpRequest) -> bool:
"""Check if the current page can be accessed without mfa."""
match = request.resolver_match
return (
None
if match is None
else any(ref in self.app_names for ref in match.app_names)
or match.url_name in self.allowed_pages
or match.route == 'favicon.ico'
)
def is_multifactor_logged_in(self, request: HttpRequest) -> bool:
"""Check if the user is logged in with multifactor authentication."""
from allauth.account.authentication import get_authentication_records
from allauth.mfa.utils import is_mfa_enabled
from allauth.mfa.webauthn.internal.flows import did_use_passwordless_login
authns = get_authentication_records(request)
return is_mfa_enabled(request.user) and (
did_use_passwordless_login(request)
or any(record.get('method') == 'mfa' for record in authns)
)
def process_view(
self, request: HttpRequest, view_func, view_args, view_kwargs
) -> HttpResponse:
"""Determine if the server is set up enforce 2fa registration."""
from django.conf import settings
# Exit early if MFA is not enabled
if not settings.MFA_ENABLED:
return None
if request.user.is_anonymous:
return None
if self.is_allowed_page(request):
return None
if self.is_multifactor_logged_in(request):
return None
if self.enforce_2fa(request):
return self.on_require_2fa(request)
return None
def enforce_2fa(self, request):
"""Use setting to check if MFA should be enforced."""
@@ -29,6 +29,8 @@ from common.currency import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers import str2bool
from .setting.storages import StorageBackends
# region path filtering
class FilterableSerializerField:
@@ -613,6 +615,8 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
if not value:
return None
if settings.STORAGE_TARGET == StorageBackends.S3:
return str(value.url)
return os.path.join(str(settings.MEDIA_URL), str(value))
@@ -627,6 +631,8 @@ class InvenTreeImageSerializerField(serializers.ImageField):
if not value:
return None
if settings.STORAGE_TARGET == StorageBackends.S3:
return str(value.url)
return os.path.join(str(settings.MEDIA_URL), str(value))
@@ -0,0 +1 @@
"""Sub-setting files."""
@@ -0,0 +1,44 @@
"""Configuration options for django-markdownify.
Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
"""
def markdownify_config():
"""Return configuration dictionary for django-markdownify."""
return {
'default': {
'BLEACH': True,
'WHITELIST_ATTRS': ['href', 'src', 'alt'],
'MARKDOWN_EXTENSIONS': ['markdown.extensions.extra'],
'WHITELIST_TAGS': [
'a',
'abbr',
'b',
'blockquote',
'code',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'hr',
'i',
'img',
'li',
'ol',
'p',
'pre',
's',
'strong',
'table',
'thead',
'tbody',
'th',
'tr',
'td',
'ul',
],
}
}
@@ -0,0 +1,41 @@
"""Configuration options for drf-spectacular."""
from InvenTree.version import inventreeApiVersion
def get_spectacular_settings():
"""Return configuration dictionary for drf-spectacular."""
return {
'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
'LICENSE': {
'name': 'MIT',
'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE',
},
'EXTERNAL_DOCS': {
'description': 'More information about InvenTree in the official docs',
'url': 'https://docs.inventree.org',
},
'VERSION': str(inventreeApiVersion()),
'SERVE_INCLUDE_SCHEMA': False,
'SCHEMA_PATH_PREFIX': '/api/',
'POSTPROCESSING_HOOKS': [
'drf_spectacular.hooks.postprocess_schema_enums',
'InvenTree.schema.postprocess_required_nullable',
'InvenTree.schema.postprocess_print_stats',
],
'ENUM_NAME_OVERRIDES': {
'UserTypeEnum': 'users.models.UserProfile.UserType',
'TemplateModelTypeEnum': 'report.models.ReportTemplateBase.ModelChoices',
'AttachmentModelTypeEnum': 'common.models.Attachment.ModelChoices',
'DataImportSessionModelTypeEnum': 'importer.models.DataImportSession.ModelChoices',
# Allauth
'UnauthorizedStatus': [[401, 401]],
'IsTrueEnum': [[True, True]],
},
# oAuth2
'OAUTH2_FLOWS': ['authorizationCode', 'clientCredentials'],
'OAUTH2_AUTHORIZATION_URL': '/o/authorize/',
'OAUTH2_TOKEN_URL': '/o/token/',
'OAUTH2_REFRESH_URL': '/o/revoke_token/',
}
@@ -0,0 +1,111 @@
"""Settings for storage backends."""
from enum import Enum
from typing import Optional
from InvenTree.config import get_boolean_setting, get_setting
class StorageBackends(str, Enum):
"""Enumeration of available storage backends."""
LOCAL = 'local'
S3 = 's3'
SFTP = 'sftp'
STORAGE_BACKEND_MAPPING = {
StorageBackends.LOCAL: 'django.core.files.storage.FileSystemStorage',
StorageBackends.S3: 'storages.backends.s3.S3Storage',
StorageBackends.SFTP: 'storages.backends.sftpstorage.SFTPStorage',
}
def init_storages() -> tuple[str, dict, Optional[str]]:
"""Initialize storage backend settings."""
target = get_setting(
'INVENTREE_STORAGE_TARGET',
'storage.target',
StorageBackends.LOCAL.value,
typecast=str,
)
# Check that the target is valid
if target not in STORAGE_BACKEND_MAPPING:
raise ValueError(f"Invalid storage target: '{target}'")
options = {}
media_url: Optional[str] = None
if target == StorageBackends.S3.value:
s3_bucket = get_setting(
'INVENTREE_S3_BUCKET_NAME', 'storage.s3.bucket_name', None, typecast=str
)
s3_acl = get_setting(
'INVENTREE_S3_DEFAULT_ACL', 'storage.s3.default_acl', None, typecast=str
)
s3_endpoint = get_setting(
'INVENTREE_S3_ENDPOINT_URL', 'storage.s3.endpoint_url', None, typecast=str
)
s3_location = get_setting(
'INVENTREE_S3_LOCATION',
'storage.s3.location',
'inventree-server',
typecast=str,
)
media_url = f'{s3_endpoint}/{s3_bucket}/{s3_location}/'
options = {
'access_key': get_setting(
'INVENTREE_S3_ACCESS_KEY', 'storage.s3.access_key', None, typecast=str
),
'secret_key': get_setting(
'INVENTREE_S3_SECRET_KEY', 'storage.s3.secret_key', None, typecast=str
),
'bucket_name': s3_bucket,
'default_acl': s3_acl,
'region_name': get_setting(
'INVENTREE_S3_REGION_NAME', 'storage.s3.region_name', None, typecast=str
),
'endpoint_url': s3_endpoint,
'verify': get_boolean_setting(
'INVENTREE_S3_VERIFY_SSL', 'storage.s3.verify_ssl', True
),
'location': s3_location,
'file_overwrite': get_boolean_setting(
'INVENTREE_S3_OVERWRITE', 'storage.s3.overwrite', True
),
'addressing_style': 'virtual'
if get_boolean_setting('INVENTREE_S3_VIRTUAL', 'storage.s3.virtual', False)
else 'path',
'object_parameters': {'CacheControl': 'public,max-age=86400'},
}
elif target == StorageBackends.SFTP:
options = {
'host': get_setting('INVENTREE_SFTP_HOST', 'sftp.host', None, typecast=str),
'uid': get_setting('INVENTREE_SFTP_UID', 'sftp.uid', None, typecast=int),
'gid': get_setting('INVENTREE_SFTP_GID', 'sftp.gid', None, typecast=int),
'location': get_setting(
'INVENTREE_SFTP_LOCATION',
'sftp.location',
'inventree-server',
typecast=str,
),
'params': get_setting(
'INVENTREE_SFTP_PARAMS', 'sftp.params', {}, typecast=dict
),
}
return (
target,
{
'default': {
'BACKEND': STORAGE_BACKEND_MAPPING.get(
target, STORAGE_BACKEND_MAPPING[StorageBackends.LOCAL]
),
'OPTIONS': options,
},
'staticfiles': {
'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage'
},
},
media_url,
)
+33 -91
View File
@@ -34,14 +34,11 @@ from InvenTree.config import (
)
from InvenTree.ready import isInMainThread
from InvenTree.sentry import default_sentry_dsn, init_sentry
from InvenTree.version import (
checkMinPythonVersion,
inventreeApiVersion,
inventreeCommitHash,
)
from InvenTree.version import checkMinPythonVersion, inventreeCommitHash
from users.oauth2_scopes import oauth2_scopes
from . import config, locales
from . import config
from .setting import locales, markdown, spectacular, storages
try:
import django_stubs_ext
@@ -264,13 +261,9 @@ DBBACKUP_EMAIL_SUBJECT_PREFIX = InvenTree.backup.backup_email_prefix()
DBBACKUP_CONNECTORS = {'default': InvenTree.backup.get_backup_connector_options()}
# Data storage options
STORAGES = {
'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'},
'dbbackup': {
'BACKEND': InvenTree.backup.get_backup_storage_backend(),
'OPTIONS': InvenTree.backup.get_backup_storage_options(),
},
DBBACKUP_STORAGE_CONFIG = {
'BACKEND': InvenTree.backup.get_backup_storage_backend(),
'OPTIONS': InvenTree.backup.get_backup_storage_options(),
}
# Enable django admin interface?
@@ -343,6 +336,7 @@ INSTALLED_APPS = [
'django_ical', # For exporting calendars
'django_mailbox', # For email import
'anymail', # For email sending/receiving via ESPs
'storages',
]
MIDDLEWARE = CONFIG.get(
@@ -1380,15 +1374,22 @@ ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
HEADLESS_ONLY = True
HEADLESS_CLIENTS = 'browser'
MFA_ENABLED = get_boolean_setting(
'INVENTREE_MFA_ENABLED', 'mfa_enabled', True
) # TODO re-implement
MFA_SUPPORTED_TYPES = get_setting(
'INVENTREE_MFA_SUPPORTED_TYPES',
'mfa_supported_types',
['totp', 'recovery_codes', 'webauthn'],
typecast=list,
MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True)
if not MFA_ENABLED:
MIDDLEWARE.remove('InvenTree.middleware.Check2FAMiddleware')
MFA_SUPPORTED_TYPES = (
get_setting(
'INVENTREE_MFA_SUPPORTED_TYPES',
'mfa_supported_types',
['totp', 'recovery_codes', 'webauthn'],
typecast=list,
)
if MFA_ENABLED
else []
)
MFA_TRUST_ENABLED = True
MFA_PASSKEY_LOGIN_ENABLED = True
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG
@@ -1401,42 +1402,7 @@ LOGOUT_REDIRECT_URL = get_setting(
# Markdownify configuration
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
MARKDOWNIFY = {
'default': {
'BLEACH': True,
'WHITELIST_ATTRS': ['href', 'src', 'alt'],
'MARKDOWN_EXTENSIONS': ['markdown.extensions.extra'],
'WHITELIST_TAGS': [
'a',
'abbr',
'b',
'blockquote',
'code',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'hr',
'i',
'img',
'li',
'ol',
'p',
'pre',
's',
'strong',
'table',
'thead',
'tbody',
'th',
'tr',
'td',
'ul',
],
}
}
MARKDOWNIFY = markdown.markdownify_config()
# Ignore these error types for in-database error logging
IGNORED_ERRORS = [Http404, HttpResponseGone, django.core.exceptions.PermissionDenied]
@@ -1513,40 +1479,8 @@ SESAME_MAX_AGE = 300
LOGIN_REDIRECT_URL = '/api/auth/login-redirect/'
# Configuration for API schema generation / oAuth2
SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
'LICENSE': {
'name': 'MIT',
'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE',
},
'EXTERNAL_DOCS': {
'description': 'More information about InvenTree in the official docs',
'url': 'https://docs.inventree.org',
},
'VERSION': str(inventreeApiVersion()),
'SERVE_INCLUDE_SCHEMA': False,
'SCHEMA_PATH_PREFIX': '/api/',
'POSTPROCESSING_HOOKS': [
'drf_spectacular.hooks.postprocess_schema_enums',
'InvenTree.schema.postprocess_required_nullable',
'InvenTree.schema.postprocess_print_stats',
],
'ENUM_NAME_OVERRIDES': {
'UserTypeEnum': 'users.models.UserProfile.UserType',
'TemplateModelTypeEnum': 'report.models.ReportTemplateBase.ModelChoices',
'AttachmentModelTypeEnum': 'common.models.Attachment.ModelChoices',
'DataImportSessionModelTypeEnum': 'importer.models.DataImportSession.ModelChoices',
# Allauth
'UnauthorizedStatus': [[401, 401]],
'IsTrueEnum': [[True, True]],
},
# oAuth2
'OAUTH2_FLOWS': ['authorizationCode', 'clientCredentials'],
'OAUTH2_AUTHORIZATION_URL': '/o/authorize/',
'OAUTH2_TOKEN_URL': '/o/token/',
'OAUTH2_REFRESH_URL': '/o/revoke_token/',
}
SPECTACULAR_SETTINGS = spectacular.get_spectacular_settings()
OAUTH2_PROVIDER = {
# default scopes
'SCOPES': oauth2_scopes,
@@ -1562,3 +1496,11 @@ OAUTH2_CHECK_EXCLUDED = [ # This setting mutes schema checks for these rule/met
if SITE_URL and not TESTING: # pragma: no cover
SPECTACULAR_SETTINGS['SERVERS'] = [{'url': SITE_URL}]
# Storage backends
STORAGE_TARGET, STORAGES, _media = storages.init_storages()
if 'dbbackup' not in STORAGES:
STORAGES['dbbackup'] = DBBACKUP_STORAGE_CONFIG
if _media:
MEDIA_URL = _media
PRESIGNED_URL_EXPIRATION = 600
+6
View File
@@ -661,6 +661,12 @@ def get_migration_plan():
return plan
def get_migration_count():
"""Returns the number of all detected migrations."""
executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
return executor.loader.applied_migrations
@tracer.start_as_current_span('check_for_migrations')
@scheduled_task(ScheduledTask.DAILY)
def check_for_migrations(force: bool = False, reload_registry: bool = True) -> bool:
+1 -1
View File
@@ -18,7 +18,7 @@ from django.conf import settings
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = '1.1.0 dev'
INVENTREE_SW_VERSION = '1.2.0 dev'
logger = logging.getLogger('inventree')
+9 -3
View File
@@ -12,6 +12,12 @@ def auth_request(request):
Useful for (for example) redirecting authentication requests through django's permission framework.
"""
if request.user and request.user.is_authenticated:
return HttpResponse(status=200)
return HttpResponse(status=403)
if not request.user or not request.user.is_authenticated:
return HttpResponse(status=403)
if not request.user.is_active:
# Reject requests from inactive users
return HttpResponse(status=403)
# User is authenticated and active
return HttpResponse(status=200)
+2
View File
@@ -597,6 +597,7 @@ class BuildLineList(
ordering_fields = [
'part',
'allocated',
'category',
'consumed',
'reference',
'quantity',
@@ -613,6 +614,7 @@ class BuildLineList(
'part': 'bom_item__sub_part__name',
'reference': 'bom_item__reference',
'unit_quantity': 'bom_item__quantity',
'category': 'bom_item__sub_part__category__name',
'consumable': 'bom_item__consumable',
'optional': 'bom_item__optional',
'trackable': 'bom_item__sub_part__trackable',
@@ -1339,6 +1339,7 @@ class BuildLineSerializer(
'bom_item_detail',
'assembly_detail',
'part_detail',
'category_detail',
'build_detail',
]
read_only_fields = ['build', 'bom_item', 'allocations']
@@ -1430,6 +1431,17 @@ class BuildLineSerializer(
True,
)
category_detail = enable_filter(
part_serializers.CategorySerializer(
label=_('Category'),
source='bom_item.sub_part.category',
many=False,
read_only=True,
allow_null=True,
),
False,
)
build_detail = enable_filter(
BuildSerializer(
label=_('Build'),
@@ -1505,6 +1517,7 @@ class BuildLineSerializer(
'bom_item',
'bom_item__part',
'bom_item__sub_part',
'bom_item__sub_part__category',
'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations',
+1
View File
@@ -669,6 +669,7 @@ class SupplierPartManager(models.Manager):
class SupplierPart(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
@@ -229,3 +229,30 @@ ldap:
#global_settings:
# INVENTREE_DEFAULT_CURRENCY: 'CNY'
# INVENTREE_RESTRICT_ABOUT: true
# Storage configuration
# Ref: https://docs.inventree.org/en/stable/start/config/#storage-backends
storage:
target: local # s3, sftp
# s3:
# access_key: 'abc123-key'
# secret_key: 'abc123-secret'
# bucket_name: 'my-bucket'
# region_name: 'fsn1'
# endpoint_url: 'https://fsn1.your-objectstorage.com'
# location: 'inventree-server_subdir'
# default_acl: private
# verify_ssl: true
# overwrite: true
# virtual: true
# sftp:
# host: 'sftp://ftp-target.example.org:22'
# uid: 1000
# gid: 1000
# location: 'inventree-server_subdir'
# params:
# # See https://docs.paramiko.org/en/latest/api/client.html#paramiko.client.SSHClient.connect
# port: 22
# username: 'user'
# password: 'pwd'
# compress: False
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -15,6 +15,7 @@ from rest_framework import serializers
from rest_framework.response import Response
import part.filters
import part.tasks as part_tasks
from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import (
BulkCreateMixin,
@@ -47,6 +48,7 @@ from InvenTree.mixins import (
SerializerContextMixin,
UpdateAPI,
)
from InvenTree.tasks import offload_task
from stock.models import StockLocation
from . import serializers as part_serializers
@@ -633,7 +635,14 @@ class PartValidateBOM(RetrieveUpdateAPI):
valid = str2bool(serializer.validated_data.get('valid', False))
part.validate_bom(request.user, valid=valid)
# BOM validation may take some time, so we offload it to a background task
offload_task(
part_tasks.validate_bom,
part.pk,
valid,
user_id=request.user.pk if request and request.user else None,
group='part',
)
# Re-serialize the response
serializer = self.get_serializer(part, many=False)
@@ -1663,6 +1672,7 @@ class BomList(
ordering_fields = [
'can_build',
'category',
'quantity',
'setup_quantity',
'attrition',
@@ -1683,6 +1693,7 @@ class BomList(
]
ordering_field_aliases = {
'category': 'sub_part__category__name',
'sub_part': 'sub_part__name',
'pricing_min': 'sub_part__pricing_data__overall_min',
'pricing_max': 'sub_part__pricing_data__overall_max',
+16 -3
View File
@@ -260,7 +260,7 @@ class PartThumbSerializer(serializers.Serializer):
Used to serve and display existing Part images.
"""
image = serializers.URLField(read_only=True)
image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True)
count = serializers.IntegerField(read_only=True)
@@ -1668,13 +1668,11 @@ class BomItemSerializer(
'rounding_multiple',
'note',
'pk',
'part_detail',
'pricing_min',
'pricing_max',
'pricing_min_total',
'pricing_max_total',
'pricing_updated',
'sub_part_detail',
'substitutes',
'validated',
# Annotated fields describing available quantity
@@ -1688,6 +1686,10 @@ class BomItemSerializer(
'building',
# Annotate the total potential quantity we can build
'can_build',
# Optional detail fields
'part_detail',
'sub_part_detail',
'category_detail',
]
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
@@ -1744,6 +1746,17 @@ class BomItemSerializer(
True,
)
category_detail = enable_filter(
CategorySerializer(
source='sub_part.category',
label=_('Category'),
many=False,
read_only=True,
allow_null=True,
),
False,
)
on_order = serializers.FloatField(
label=_('On Order'), read_only=True, allow_null=True
)
+33
View File
@@ -1,6 +1,7 @@
"""Background task definitions for the 'part' app."""
from datetime import datetime, timedelta
from typing import Optional
from django.core.exceptions import ValidationError
from django.db.models import Model
@@ -437,3 +438,35 @@ def check_bom_valid(part_id: int):
if valid != part.bom_validated:
part.bom_validated = valid
part.save()
@tracer.start_as_current_span('validate_bom')
def validate_bom(part_id: int, valid: bool, user_id: Optional[int] = None):
"""Run BOM validation for the specified Part.
Arguments:
part_id: The ID of the part for which to validate the BOM.
valid: Boolean indicating whether the BOM is valid or not.
user_id: Optional ID of the user performing the validation.
"""
from django.contrib.auth import get_user_model
from part.models import Part
User = get_user_model()
try:
part = Part.objects.get(pk=part_id)
except Part.DoesNotExist:
logger.warning('validate_bom: Part with ID %s does not exist', part_id)
return
if user_id:
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
user = None
else:
user = None
part.validate_bom(user, valid=valid)
+18 -1
View File
@@ -15,6 +15,7 @@ logger = structlog.get_logger('inventree')
# List of supported feature types
FeatureType = Literal[
'spotlight_action', # Custom actions for the spotlight search
'dashboard', # Custom dashboard items
'panel', # Custom panels
'template_editor', # Custom template editor
@@ -99,11 +100,12 @@ class UserInterfaceMixin:
"""
feature_map = {
'spotlight_action': self.get_ui_spotlight_actions,
'dashboard': self.get_ui_dashboard_items,
'navigation': self.get_ui_navigation_items,
'panel': self.get_ui_panels,
'template_editor': self.get_ui_template_editors,
'template_preview': self.get_ui_template_previews,
'navigation': self.get_ui_navigation_items,
}
if feature_type in feature_map:
@@ -112,6 +114,21 @@ class UserInterfaceMixin:
logger.warning(f'Invalid feature type: {feature_type}')
return []
def get_ui_spotlight_actions(
self, request: Request, context: dict, **kwargs
) -> list[UIFeature]:
"""Return a list of custom actions to be injected into the UI spotlight.
Args:
request: HTTPRequest object (including user information)
context: Additional context data provided by the UI (query parameters)
Returns:
list: A list of custom actions to be injected into the UI spotlight.
"""
# Default implementation returns an empty list
return []
def get_ui_panels(
self, request: Request, context: dict, **kwargs
) -> list[UIFeature]:
@@ -49,6 +49,20 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
},
}
def get_ui_spotlight_actions(self, request, context, **kwargs):
"""Return a list of custom actions to be injected into the UI spotlight."""
return [
{
'key': 'sample-action',
'title': 'Sample Action',
'description': 'This is a sample action for the spotlight search',
'icon': 'ti:search:outline',
'source': self.plugin_static_file(
'sample_action.js:performSampleAction'
),
}
]
def get_ui_panels(self, request, context, **kwargs):
"""Return a list of custom panels to be injected into the UI."""
panels = []
@@ -0,0 +1,12 @@
/**
* A sample action plugin for InvenTree.
*
* This is a very basic example of how to define a custom action.
* In practice, you would want to implement more complex logic here.
*/
export function performSampleAction(data) {
// Simply log the data to the console
alert("Sample! Refer to the console");
console.log("Sample action performed with data:", data);
}
@@ -15,8 +15,12 @@ from django.db.models.query import QuerySet
from django.utils.safestring import SafeString, mark_safe
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.money import Money
from PIL import Image
import common.currency
import common.icons
import InvenTree.helpers
import InvenTree.helpers_model
@@ -451,7 +455,17 @@ def cast_to_type(value: Any, cast: type) -> Any:
def debug_vars(x: Any, y: Any) -> str:
"""Return a debug string showing the types and values of two variables."""
return f": x='{x}' ({type(x).__name__}), y='{y}' ({type(y).__name__})"
return f"x='{x}' ({type(x).__name__}), y='{y}' ({type(y).__name__})"
def check_nulls(func: str, *arg):
"""Check if any of the provided arguments is null.
Raises:
ValueError: If any argument is None
"""
if any(a is None for a in arg):
raise ValidationError(f'{func}: {_("Null value provided to function")}')
@register.simple_tag()
@@ -466,11 +480,13 @@ def add(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be added together
"""
check_nulls('add', x, y)
try:
result = make_decimal(x) + make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot add values of incompatible types') + debug_vars(x, y)
f'add: {_("Cannot add values of incompatible types")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -487,11 +503,13 @@ def subtract(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be subtracted
"""
check_nulls('subtract', x, y)
try:
result = make_decimal(x) - make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot subtract values of incompatible types') + debug_vars(x, y)
f'subtract: {_("Cannot subtract values of incompatible types")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -509,11 +527,13 @@ def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be multiplied together
"""
check_nulls('multiply', x, y)
try:
result = make_decimal(x) * make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot multiply values of incompatible types') + debug_vars(x, y)
f'multiply: {_("Cannot multiply values of incompatible types")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -531,14 +551,18 @@ def divide(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be divided
"""
check_nulls('divide', x, y)
try:
result = make_decimal(x) / make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot divide values of incompatible types') + debug_vars(x, y)
f'divide: {_("Cannot divide values of incompatible types")}: {debug_vars(x, y)}'
)
except ZeroDivisionError:
raise ValidationError(_('Cannot divide by zero') + debug_vars(x, y))
raise ValidationError(
f'divide: {_("Cannot divide by zero")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -555,16 +579,17 @@ def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be used in a modulo operation
"""
check_nulls('modulo', x, y)
try:
result = make_decimal(x) % make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot perform modulo operation with values of incompatible types')
+ debug_vars(x, y)
f'modulo: {_("Cannot perform modulo operation with values of incompatible types")} {debug_vars(x, y)}'
)
except ZeroDivisionError:
raise ValidationError(
_('Cannot perform modulo operation with divisor of zero') + debug_vars(x, y)
f'modulo: {_("Cannot perform modulo operation with divisor of zero")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -576,6 +601,70 @@ def render_currency(money, **kwargs):
return InvenTree.helpers_model.render_currency(money, **kwargs)
@register.simple_tag
def create_currency(
amount: Union[str, int, float, Decimal], currency: Optional[str] = None, **kwargs
):
"""Create a Money object, with the provided amount and currency.
Arguments:
amount: The numeric amount (a numeric type or string)
currency: The currency code (e.g. 'USD', 'EUR', etc.)
Note: If the currency is not provided, the default system currency will be used.
"""
check_nulls('create_currency', amount)
currency = currency or common.currency.currency_code_default()
currency = currency.strip().upper()
if currency not in common.currency.CURRENCIES:
raise ValidationError(
f'create_currency: {_("Invalid currency code")}: {currency}'
)
try:
money = Money(amount, currency)
except InvalidOperation:
raise ValidationError(f'create_currency: {_("Invalid amount")}: {amount}')
return money
@register.simple_tag
def convert_currency(money: Money, currency: Optional[str] = None, **kwargs):
"""Convert a Money object to the specified currency.
Arguments:
money: The Money instance to be converted
currency: The target currency code (e.g. 'USD', 'EUR', etc.)
Note: If the currency is not provided, the default system currency will be used.
"""
check_nulls('convert_currency', money)
if not isinstance(money, Money):
raise TypeError('convert_currency tag requires a Money instance')
currency = currency or common.currency.currency_code_default()
currency = currency.strip().upper()
if currency not in common.currency.CURRENCIES:
raise ValidationError(
f'convert_currency: {_("Invalid currency code")}: {currency}'
)
try:
converted = convert_money(money, currency)
except MissingRate:
# Re-throw error with more context
raise ValidationError(
f'convert_currency: {_("Missing exchange rate")} {money.currency} -> {currency}'
)
return converted
@register.simple_tag
def render_html_text(text: str, **kwargs):
"""Render a text item with some simple html tags.
@@ -607,6 +696,7 @@ def render_html_text(text: str, **kwargs):
def format_number(
number: Union[int, float, Decimal],
decimal_places: Optional[int] = None,
multiplier: Optional[Union[int, float, Decimal]] = None,
integer: bool = False,
leading: int = 0,
separator: Optional[str] = None,
@@ -616,16 +706,22 @@ def format_number(
Arguments:
number: The number to be formatted
decimal_places: Number of decimal places to render
multiplier: Optional multiplier to apply to the number before formatting
integer: Boolean, whether to render the number as an integer
leading: Number of leading zeros (default = 0)
separator: Character to use as a thousands separator (default = None)
"""
check_nulls('format_number', number)
try:
number = Decimal(str(number))
number = Decimal(str(number).strip())
except Exception:
# If the number cannot be converted to a Decimal, just return the original value
return str(number)
if multiplier is not None:
number *= Decimal(str(multiplier).strip())
if integer:
# Convert to integer
number = Decimal(int(number))
@@ -641,7 +737,13 @@ def format_number(
pass
# Re-encode, and normalize again
value = Decimal(number).normalize()
# Ensure that the output never uses scientific notation
value = Decimal(number)
value = (
value.quantize(Decimal(1))
if value == value.to_integral()
else value.normalize()
)
if separator:
value = f'{value:,}'
@@ -670,6 +772,8 @@ def format_datetime(
timezone: The timezone to use for the date (defaults to the server timezone)
fmt: The format string to use (defaults to ISO formatting)
"""
check_nulls('format_datetime', dt)
dt = InvenTree.helpers.to_local_time(dt, timezone)
if fmt:
@@ -687,6 +791,8 @@ def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = N
timezone: The timezone to use for the date (defaults to the server timezone)
fmt: The format string to use (defaults to ISO formatting)
"""
check_nulls('format_date', dt)
try:
dt = InvenTree.helpers.to_local_time(dt, timezone).date()
except TypeError:
+64
View File
@@ -270,7 +270,19 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
"""Simple tests for number formatting tags."""
fn = report_tags.format_number
# Passing None should raise an error
with self.assertRaises(ValidationError):
fn(None)
for i in [1, '1', '1.0000', ' 1 ']:
self.assertEqual(fn(i), '1')
for x in ['10.000000', ' 10 ', 10.000000, 10]:
self.assertEqual(fn(x), '10')
self.assertEqual(fn(1234), '1234')
self.assertEqual(fn(1234.5678, decimal_places=0), '1235')
self.assertEqual(fn(1234.5678, decimal_places=1), '1234.6')
self.assertEqual(fn(1234.5678, decimal_places=2), '1234.57')
self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
self.assertEqual(fn(-9999.5678, decimal_places=2, separator=','), '-9,999.57')
@@ -278,6 +290,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
fn(9988776655.4321, integer=True, separator=' '), '9 988 776 655'
)
# Test with multiplier
self.assertEqual(fn(1000, multiplier=1.5), '1500')
# Failure cases
self.assertEqual(fn('abc'), 'abc')
self.assertEqual(fn(1234.456, decimal_places='a'), '1234.456')
@@ -438,6 +453,55 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
self.assertEqual(report_tags.render_currency(m, min_decimal_places='a'), exp_m)
self.assertEqual(report_tags.render_currency(m, max_decimal_places='a'), exp_m)
def test_create_currency(self):
"""Test the create_currency template tag."""
m = report_tags.create_currency(1000, 'USD')
self.assertIsInstance(m, Money)
self.assertEqual(m.amount, Decimal('1000'))
self.assertEqual(str(m.currency), 'USD')
# Test with invalid currency code
with self.assertRaises(ValidationError):
report_tags.create_currency(1000, 'QWERTY')
# Test with invalid amount
with self.assertRaises(ValidationError):
report_tags.create_currency('abc', 'USD')
def test_convert_currency(self):
"""Test the convert_currency template tag."""
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
# Generate some dummy exchange rates
rates = {'AUD': 1.5, 'CAD': 1.7, 'GBP': 0.9, 'USD': 1.0}
# Create a dummy backend
ExchangeBackend.objects.create(name='InvenTreeExchange', base_currency='USD')
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
items = []
for currency, rate in rates.items():
items.append(Rate(currency=currency, value=rate, backend=backend))
Rate.objects.bulk_create(items)
m = report_tags.create_currency(1000, 'GBP')
# Test with valid conversion
converted = report_tags.convert_currency(m, 'CAD')
self.assertIsInstance(converted, Money)
self.assertEqual(str(converted.currency), 'CAD')
# Test with invalid currency code
with self.assertRaises(ValidationError):
report_tags.convert_currency(m, 'QWERTY')
# Test with missing exchange rate
with self.assertRaises(ValidationError):
report_tags.convert_currency(m, 'AFD')
def test_render_html_text(self):
"""Test the render_html_text template tag."""
# Test with a valid text
+5 -1
View File
@@ -1290,10 +1290,14 @@ class StockList(
search_fields = [
'serial',
'batch',
'location__name',
'part__name',
'part__IPN',
'part__description',
'location__name',
'supplier_part__SKU',
'supplier_part__supplier__name',
'supplier_part__manufacturer_part__MPN',
'supplier_part__manufacturer_part__manufacturer__name',
'tags__name',
'tags__slug',
]
+6 -15
View File
@@ -1,7 +1,5 @@
"""Unit tests for the 'users' app."""
from time import sleep
from django.apps import apps
from django.contrib.auth.models import Group
from django.test import TestCase
@@ -351,20 +349,13 @@ class MFALoginTest(InvenTreeAPITestCase):
response = self.post(login_url, auth_data, expected_code=200)
self._helper_meta_val(response)
return # TODO @matmair re-enable MFA tests once stable
# Add MFA - trying in a limited loop in case of timing issues
success: bool = False
for _ in range(10):
try:
response = self.post(
reverse('browser:mfa:manage_totp'),
{'code': self.get_topt()},
expected_code=200,
)
success = True
break
except AssertionError:
sleep(0.8)
self.assertTrue(success, 'Failed to add MFA device')
response = self.post(
reverse('browser:mfa:manage_totp'),
{'code': self.get_topt()},
expected_code=200,
)
# There must be a TOTP device now - success
self.get(reverse('browser:mfa:manage_totp'), expected_code=200)
+3 -3
View File
@@ -382,9 +382,9 @@ distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
django==4.2.25 \
--hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
--hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
django==4.2.26 \
--hash=sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a \
--hash=sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280
# via
# -c src/backend/requirements.txt
# django-slowtests
+2 -1
View File
@@ -1,5 +1,5 @@
# Please keep this list sorted - if you pin a version provide a reason
Django<5.0 # Django package
Django<6.0 # Django package
blessed # CLI for Q Monitor
cryptography>=44.0.0 # Core cryptographic functionality
django-anymail[amazon_ses,postal] # Email backend for various providers
@@ -26,6 +26,7 @@ django-sql-utils # Advanced query annotation / aggregatio
django-sslserver # Secure HTTP development server
django-structlog # Structured logging
django-stdimage # Advanced ImageField management
django-storages[s3,sftp] # Storage backends for Django
django-taggit # Tagging support
django-otp==1.3.0 # Two-factor authentication (legacy to ensure migrations) https://github.com/inventree/InvenTree/pull/6293
django-oauth-toolkit # OAuth2 provider
+109 -10
View File
@@ -22,6 +22,59 @@ babel==2.17.0 \
--hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \
--hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2
# via py-moneyed
bcrypt==4.3.0 \
--hash=sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f \
--hash=sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d \
--hash=sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24 \
--hash=sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3 \
--hash=sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c \
--hash=sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d \
--hash=sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd \
--hash=sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f \
--hash=sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f \
--hash=sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d \
--hash=sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe \
--hash=sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231 \
--hash=sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef \
--hash=sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18 \
--hash=sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f \
--hash=sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e \
--hash=sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732 \
--hash=sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304 \
--hash=sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0 \
--hash=sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8 \
--hash=sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938 \
--hash=sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62 \
--hash=sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180 \
--hash=sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af \
--hash=sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669 \
--hash=sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761 \
--hash=sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51 \
--hash=sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23 \
--hash=sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09 \
--hash=sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505 \
--hash=sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4 \
--hash=sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753 \
--hash=sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59 \
--hash=sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b \
--hash=sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d \
--hash=sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a \
--hash=sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b \
--hash=sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a \
--hash=sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90 \
--hash=sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492 \
--hash=sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce \
--hash=sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb \
--hash=sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb \
--hash=sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1 \
--hash=sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676 \
--hash=sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b \
--hash=sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe \
--hash=sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281 \
--hash=sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1 \
--hash=sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef \
--hash=sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d
# via paramiko
bleach[css]==6.2.0 \
--hash=sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e \
--hash=sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f
@@ -33,7 +86,9 @@ blessed==1.22.0 \
boto3==1.40.55 \
--hash=sha256:27e35b4fa9edd414ce06c1a748bf57cacd8203271847d93fc1053e4a4ec6e1a9 \
--hash=sha256:2e30f5a0d49e107b8a5c0c487891afd300bfa410e1d918bf187ae45ac3839332
# via django-anymail
# via
# django-anymail
# django-storages
botocore==1.40.55 \
--hash=sha256:79b6472e2de92b3519d44fc1eec8c5feced7f99a0d10fdea6dc93133426057c1 \
--hash=sha256:cdc38f7a4ddb30a2cd1cdd4fabde2a5a16e41b5a642292e1c30de5c4e46f5d44
@@ -260,6 +315,7 @@ cffi==2.0.0 \
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
# via
# cryptography
# pynacl
# weasyprint
charset-normalizer==3.4.4 \
--hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \
@@ -420,6 +476,7 @@ cryptography==44.0.3 \
# djangorestframework-simplejwt
# fido2
# jwcrypto
# paramiko
# pyjwt
cssselect2==0.8.0 \
--hash=sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e \
@@ -429,9 +486,9 @@ defusedxml==0.7.1 \
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
# via python3-openid
django==4.2.25 \
--hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
--hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
django==4.2.26 \
--hash=sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a \
--hash=sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280
# via
# -r src/backend/requirements.in
# django-allauth
@@ -455,6 +512,7 @@ django==4.2.25 \
# django-sql-utils
# django-sslserver
# django-stdimage
# django-storages
# django-structlog
# django-taggit
# django-xforwardedfor-middleware
@@ -565,6 +623,10 @@ django-stdimage==6.0.2 \
--hash=sha256:880ab14828be56b53f711c3afae83c219ddd5d9af00850626736feb48382bf7f \
--hash=sha256:9a73f7da48c48074580e2b032d5bdb7164935dbe4b9dc4fb88a7e112f3d521c8
# via -r src/backend/requirements.in
django-storages[s3, sftp]==1.14.6 \
--hash=sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9 \
--hash=sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9
# via -r src/backend/requirements.in
django-structlog==9.1.1 \
--hash=sha256:14342c6c824581f1e063c88a8bc52314cd67995a3bd4a4fc8c27ea37ccd78947 \
--hash=sha256:5b6ac3abdf6549e94ccb35160b1f10266f1627c3ac77844571235a08a1ddae66
@@ -798,6 +860,10 @@ inflection==0.5.1 \
--hash=sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417 \
--hash=sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2
# via drf-spectacular
invoke==2.2.1 \
--hash=sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8 \
--hash=sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707
# via paramiko
isodate==0.7.2 \
--hash=sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15 \
--hash=sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6
@@ -1202,6 +1268,10 @@ packaging==25.0 \
# via
# gunicorn
# opentelemetry-instrumentation
paramiko==4.0.0 \
--hash=sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9 \
--hash=sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f
# via django-storages
pdf2image==1.17.0 \
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
@@ -1383,6 +1453,35 @@ pyjwt[crypto]==2.10.1 \
# via
# django-allauth
# djangorestframework-simplejwt
pynacl==1.6.0 \
--hash=sha256:04f20784083014e265ad58c1b2dd562c3e35864b5394a14ab54f5d150ee9e53e \
--hash=sha256:10d755cf2a455d8c0f8c767a43d68f24d163b8fe93ccfaabfa7bafd26be58d73 \
--hash=sha256:140373378e34a1f6977e573033d1dd1de88d2a5d90ec6958c9485b2fd9f3eb90 \
--hash=sha256:16c60daceee88d04f8d41d0a4004a7ed8d9a5126b997efd2933e08e93a3bd850 \
--hash=sha256:16dd347cdc8ae0b0f6187a2608c0af1c8b7ecbbe6b4a06bff8253c192f696990 \
--hash=sha256:25720bad35dfac34a2bcdd61d9e08d6bfc6041bebc7751d9c9f2446cf1e77d64 \
--hash=sha256:2d6cd56ce4998cb66a6c112fda7b1fdce5266c9f05044fa72972613bef376d15 \
--hash=sha256:347dcddce0b4d83ed3f32fd00379c83c425abee5a9d2cd0a2c84871334eaff64 \
--hash=sha256:4853c154dc16ea12f8f3ee4b7e763331876316cc3a9f06aeedf39bcdca8f9995 \
--hash=sha256:49c336dd80ea54780bcff6a03ee1a476be1612423010472e60af83452aa0f442 \
--hash=sha256:4a25cfede801f01e54179b8ff9514bd7b5944da560b7040939732d1804d25419 \
--hash=sha256:51fed9fe1bec9e7ff9af31cd0abba179d0e984a2960c77e8e5292c7e9b7f7b5d \
--hash=sha256:536703b8f90e911294831a7fbcd0c062b837f3ccaa923d92a6254e11178aaf42 \
--hash=sha256:5789f016e08e5606803161ba24de01b5a345d24590a80323379fc4408832d290 \
--hash=sha256:6b08eab48c9669d515a344fb0ef27e2cbde847721e34bba94a343baa0f33f1f4 \
--hash=sha256:6b393bc5e5a0eb86bb85b533deb2d2c815666665f840a09e0aa3362bb6088736 \
--hash=sha256:84709cea8f888e618c21ed9a0efdb1a59cc63141c403db8bf56c469b71ad56f2 \
--hash=sha256:8bfaa0a28a1ab718bad6239979a5a57a8d1506d0caf2fba17e524dbb409441cf \
--hash=sha256:bbcc4452a1eb10cd5217318c822fde4be279c9de8567f78bad24c773c21254f8 \
--hash=sha256:cb36deafe6e2bce3b286e5d1f3e1c246e0ccdb8808ddb4550bb2792f2df298f2 \
--hash=sha256:cf831615cc16ba324240de79d925eacae8265b7691412ac6b24221db157f6bd1 \
--hash=sha256:dcdeb41c22ff3c66eef5e63049abf7639e0db4edee57ba70531fc1b6b133185d \
--hash=sha256:dea103a1afcbc333bc0e992e64233d360d393d1e63d0bc88554f572365664348 \
--hash=sha256:ef214b90556bb46a485b7da8258e59204c244b1b5b576fb71848819b468c44a7 \
--hash=sha256:f3482abf0f9815e7246d461fab597aa179b7524628a4bc36f86a7dc418d2608d \
--hash=sha256:f46386c24a65383a9081d68e9c2de909b1834ec74ff3013271f1bca9c2d233eb \
--hash=sha256:f4b3824920e206b4f52abd7de621ea7a44fd3cb5c8daceb7c3612345dfc54f2e
# via paramiko
pypdf==6.1.3 \
--hash=sha256:8d420d1e79dc1743f31a57707cabb6dcd5b17e8b9a302af64b30202c5700ab9d \
--hash=sha256:eb049195e46f014fc155f566fa20e09d70d4646a9891164ac25fa0cbcfcdbcb5
@@ -1402,9 +1501,9 @@ python-dateutil==2.9.0.post0 \
# botocore
# django-recurrence
# icalendar
python-dotenv==1.1.1 \
--hash=sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc \
--hash=sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab
python-dotenv==1.2.1 \
--hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \
--hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
# via -r src/backend/requirements.in
python-fsutil==0.15.0 \
--hash=sha256:8ae31def522916e35caf67723b8526fe6e5fcc1e160ea2dc23c845567708ca6e \
@@ -1784,9 +1883,9 @@ s3transfer==0.14.0 \
--hash=sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456 \
--hash=sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125
# via boto3
sentry-sdk==2.42.1 \
--hash=sha256:8598cc6edcfe74cb8074ba6a7c15338cdee93d63d3eb9b9943b4b568354ad5b6 \
--hash=sha256:f8716b50c927d3beb41bc88439dc6bcd872237b596df5b14613e2ade104aee02
sentry-sdk==2.43.0 \
--hash=sha256:4aacafcf1756ef066d359ae35030881917160ba7f6fc3ae11e0e58b09edc2d5d \
--hash=sha256:52ed6e251c5d2c084224d73efee56b007ef5c2d408a4a071270e82131d336e20
# via
# -r src/backend/requirements.in
# django-q-sentry
+1
View File
@@ -125,6 +125,7 @@
"@vitejs/plugin-react": "^5.0.2",
"babel-plugin-macros": "^3.1.0",
"nyc": "^17.1.0",
"otpauth": "^9.4.1",
"path": "^0.12.7",
"rollup": "^4.0.0",
"rollup-plugin-license": "^3.5.3",
@@ -1,21 +1,24 @@
import { t } from '@lingui/core/macro';
import { ActionButton } from '@lib/components/ActionButton';
import type { FloatingPosition } from '@mantine/core';
import { InvenTreeIcon } from '../../functions/icons';
export default function RemoveRowButton({
onClick,
tooltip = t`Remove this row`
tooltip = t`Remove this row`,
tooltipAlignment
}: Readonly<{
onClick: () => void;
tooltip?: string;
tooltipAlignment?: FloatingPosition;
}>) {
return (
<ActionButton
onClick={onClick}
icon={<InvenTreeIcon icon='square_x' />}
tooltip={tooltip}
tooltipAlignment='top-end'
tooltipAlignment={tooltipAlignment ?? 'top-end'}
color='red'
/>
);
@@ -2,8 +2,9 @@ import { t } from '@lingui/core/macro';
import {
Alert,
CloseButton,
Code,
Group,
List,
ListItem,
Overlay,
Stack,
Tabs
@@ -91,7 +92,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
const [hasSaveConfirmed, setHasSaveConfirmed] = useState(false);
const [previewItem, setPreviewItem] = useState<string>('');
const [errorOverlay, setErrorOverlay] = useState(null);
const [renderingErrors, setRenderingErrors] = useState<string[] | null>(null);
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
@@ -210,7 +211,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
)
)
.then(() => {
setErrorOverlay(null);
setRenderingErrors(null);
notifications.hide('template-preview');
@@ -222,7 +223,19 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
});
})
.catch((error) => {
setErrorOverlay(error.message);
const msg = error?.message;
if (msg) {
if (Array.isArray(msg)) {
setRenderingErrors(msg);
} else {
setRenderingErrors([msg]);
}
} else {
setRenderingErrors([
t`An unknown error occurred while rendering the preview.`
]);
}
})
.finally(() => {
setIsPreviewLoading(false);
@@ -392,10 +405,10 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
{/* @ts-ignore-next-line */}
<PreviewArea.component ref={previewRef} />
{errorOverlay && (
{renderingErrors && (
<Overlay color='red' center blur={0.2}>
<CloseButton
onClick={() => setErrorOverlay(null)}
onClick={() => setRenderingErrors(null)}
style={{
position: 'absolute',
top: '10px',
@@ -410,7 +423,11 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
title={t`Error rendering template`}
mx='10px'
>
<Code>{errorOverlay}</Code>
<List>
{renderingErrors.map((error, index) => (
<ListItem key={index}>{error}</ListItem>
))}
</List>
</Alert>
</Overlay>
)}
+91 -69
View File
@@ -2,12 +2,14 @@ import { ActionIcon, Alert, Group, Menu, Stack, Tooltip } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import type { SettingsStateProps } from '@lib/types/Settings';
import { t } from '@lingui/core/macro';
import { useShallow } from 'zustand/react/shallow';
import { docLinks } from '../../defaults/links';
import { useServerApiState } from '../../states/ServerApiState';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import type { ServerAPIProps } from '../../states/states';
interface AlertInfo {
key: string;
@@ -32,64 +34,21 @@ export function Alerts() {
const [dismissed, setDismissed] = useState<string[]>([]);
const alerts: AlertInfo[] = useMemo(() => {
const _alerts: AlertInfo[] = [];
if (server?.debug_mode) {
_alerts.push({
key: 'debug',
title: t`Debug Mode`,
code: 'INVE-W4',
message: t`The server is running in debug mode.`
});
}
if (!server?.worker_running) {
_alerts.push({
key: 'worker',
title: t`Background Worker`,
code: 'INVE-W5',
message: t`The background worker process is not running.`
});
}
if (!server?.email_configured) {
_alerts.push({
key: 'email',
title: t`Email settings`,
code: 'INVE-W7',
message: t`Email settings not configured.`
});
}
if (globalSettings.isSet('SERVER_RESTART_REQUIRED')) {
_alerts.push({
key: 'restart',
title: t`Server Restart`,
code: 'INVE-W6',
message: t`The server requires a restart to apply changes.`
});
}
const n_migrations =
Number.parseInt(globalSettings.getSetting('_PENDING_MIGRATIONS')) ?? 0;
if (n_migrations > 0) {
_alerts.push({
key: 'migrations',
title: t`Database Migrations`,
code: 'INVE-W8',
message: t`There are pending database migrations.`
});
}
return _alerts.filter((alert) => !dismissed.includes(alert.key));
}, [server, dismissed, globalSettings]);
const alerts: AlertInfo[] = useMemo(
() =>
getAlerts(server, globalSettings).filter(
(alert) => !dismissed.includes(alert.key)
),
[server, dismissed, globalSettings]
);
const anyErrors: boolean = useMemo(
() => alerts.some((alert) => alert.error),
[alerts]
);
function closeAlert(key: string) {
setDismissed([...dismissed, key]);
}
if (user.isStaff() && alerts.length > 0)
return (
@@ -108,22 +67,7 @@ export function Alerts() {
<Menu.Dropdown>
{alerts.map((alert) => (
<Menu.Item key={`alert-item-${alert.key}`}>
<Alert
withCloseButton
color={alert.error ? 'red' : 'orange'}
title={
<Group gap='xs'>
{alert.code && `${alert.code}: `}
{alert.title}
</Group>
}
onClose={() => setDismissed([...dismissed, alert.key])}
>
<Stack gap='xs'>
{alert.message}
{alert.code && errorCodeLink(alert.code)}
</Stack>
</Alert>
<ServerAlert alert={alert} closeAlert={closeAlert} />
</Menu.Item>
))}
</Menu.Dropdown>
@@ -131,6 +75,84 @@ export function Alerts() {
);
return null;
}
export function ServerAlert({
alert,
closeAlert
}: { alert: AlertInfo; closeAlert?: (key: string) => void }) {
return (
<Alert
withCloseButton={!!closeAlert}
color={alert.error ? 'red' : 'orange'}
title={
<Group gap='xs'>
{alert.code && `${alert.code}: `}
{alert.title}
</Group>
}
onClose={closeAlert ? () => closeAlert(alert.key) : undefined}
>
<Stack gap='xs'>
{alert.message}
{alert.code && errorCodeLink(alert.code)}
</Stack>
</Alert>
);
}
type ExtendedAlertInfo = AlertInfo & {
condition: boolean;
};
export function getAlerts(
server: ServerAPIProps,
globalSettings: SettingsStateProps,
inactive = false
): ExtendedAlertInfo[] {
const n_migrations =
Number.parseInt(globalSettings.getSetting('_PENDING_MIGRATIONS')) ?? 0;
const allalerts: ExtendedAlertInfo[] = [
{
key: 'debug',
title: t`Debug Mode`,
code: 'INVE-W4',
message: t`The server is running in debug mode.`,
condition: server?.debug_mode || false
},
{
key: 'worker',
title: t`Background Worker`,
code: 'INVE-W5',
message: t`The background worker process is not running.`,
condition: !server?.worker_running
},
{
key: 'restart',
title: t`Server Restart`,
code: 'INVE-W6',
message: t`The server requires a restart to apply changes.`,
condition: globalSettings.isSet('SERVER_RESTART_REQUIRED')
},
{
key: 'email',
title: t`Email settings`,
code: 'INVE-W7',
message: t`Email settings not configured.`,
condition: !server?.email_configured
},
{
key: 'migrations',
title: t`Database Migrations`,
code: 'INVE-W8',
message: t`There are pending database migrations.`,
condition: n_migrations > 0
}
];
return allalerts.filter((alert) => inactive || alert.condition);
}
export function errorCodeLink(code: string) {
return (
<a
+73 -15
View File
@@ -1,15 +1,33 @@
import { t } from '@lingui/core/macro';
import { Container, Flex, Space } from '@mantine/core';
import { Spotlight, createSpotlight } from '@mantine/spotlight';
import {
Spotlight,
type SpotlightActionData,
createSpotlight
} from '@mantine/spotlight';
import { IconSearch } from '@tabler/icons-react';
import { type JSX, useEffect, useState } from 'react';
import { type JSX, useEffect, useMemo, useState } from 'react';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { identifierString } from '@lib/functions/Conversion';
import { ApiEndpoints, apiUrl } from '@lib/index';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../App';
import { getActions } from '../../defaults/actions';
import * as classes from '../../main.css';
import { useUserSettingsState } from '../../states/SettingsStates';
import {
useGlobalSettingsState,
useUserSettingsState
} from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import { Boundary } from '../Boundary';
import { ApiIcon } from '../items/ApiIcon';
import { useInvenTreeContext } from '../plugins/PluginContext';
import { callExternalPluginFunction } from '../plugins/PluginSource';
import {
type PluginUIFeature,
PluginUIFeatureType
} from '../plugins/PluginUIFeature';
import { Footer } from './Footer';
import { Header } from './Header';
@@ -38,25 +56,65 @@ export const [firstStore, firstSpotlight] = createSpotlight();
export default function LayoutComponent() {
const navigate = useNavigate();
const location = useLocation();
const user = useUserState();
const userSettings = useUserSettingsState();
const globalSettings = useGlobalSettingsState();
const pluginsEnabled: boolean = useMemo(
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
[globalSettings]
);
const inventreeContext = useInvenTreeContext();
const defaultActions = getActions(navigate);
const [actions, setActions] = useState(defaultActions);
const [customActions, setCustomActions] = useState<boolean>(false);
function actionsAreChanging(change: []) {
if (change.length > defaultActions.length) setCustomActions(true);
setActions(change);
}
// firstStore.subscribe(actionsAreChanging);
const pluginActionsQuery = useQuery({
enabled: pluginsEnabled,
queryKey: ['plugin-actions', pluginsEnabled, user],
refetchOnMount: true,
queryFn: async () => {
if (!pluginsEnabled) {
return Promise.resolve([]);
}
// clear additional actions on location change
useEffect(() => {
if (customActions) {
setActions(defaultActions);
setCustomActions(false);
const url = apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, {
feature_type: PluginUIFeatureType.spotlight_action
});
return api.get(url).then((response: any) => response.data);
}
}, [location]);
});
const pluginActions: SpotlightActionData[] = useMemo(() => {
return (
pluginActionsQuery?.data?.map((item: PluginUIFeature) => {
const pluginContext = {
...inventreeContext,
context: item.context
};
return {
id: identifierString(`a-${item.plugin_name}-${item.key}`),
label: item.title,
description: item.description,
leftSection: item.icon && <ApiIcon name={item.icon} />,
onClick: () => {
callExternalPluginFunction(
item.source,
'executeAction',
pluginContext
);
}
};
}) ?? []
);
}, [pluginActionsQuery?.data, inventreeContext]);
useEffect(() => {
setActions([...defaultActions, ...pluginActions]);
}, [defaultActions.length, pluginActions.length, location]);
return (
<ProtectedRoute>
@@ -1,3 +1,4 @@
import type { InvenTreePluginContext } from '@lib/types/Plugins';
import { generateUrl } from '../../functions/urls';
import { useLocalState } from '../../states/LocalState';
@@ -56,3 +57,16 @@ export async function findExternalPluginFunction(
return null;
}
// Attempt to call an external plugin function, given the source URL and function name
export async function callExternalPluginFunction(
source: string,
functionName: string,
context: InvenTreePluginContext
): Promise<any> {
findExternalPluginFunction(source, functionName).then((func) => {
if (func) {
return func(context);
}
});
}
@@ -25,6 +25,7 @@ import type {
* Enumeration for available plugin UI feature types.
*/
export enum PluginUIFeatureType {
spotlight_action = 'spotlight_action',
dashboard = 'dashboard',
panel = 'panel',
template_editor = 'template_editor',
@@ -80,7 +80,7 @@ export function RenderSupplierPart(
const part = instance.part_detail ?? {};
const secondary: string = instance.SKU;
let suffix: string = part.full_name;
let suffix: string = part?.full_name ?? '';
if (instance.pack_quantity) {
suffix += ` (${instance.pack_quantity})`;
@@ -0,0 +1,138 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Button, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import {
IconBrandGithub,
IconListCheck,
IconUserPlus,
IconUsersGroup,
type ReactNode
} from '@tabler/icons-react';
import { ApiEndpoints } from '@lib/index';
import {
projectCodeFields,
useCustomStateFields
} from '../../forms/CommonForms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { groupFields } from '../../tables/settings/GroupTable';
import { userFields } from '../../tables/settings/UserTable';
interface ActionItem {
id: string;
title: string;
description: string;
icon?: ReactNode;
buttonText?: string;
action: () => void;
}
function ActionGrid({ items }: { items: ActionItem[] }) {
const slides = items.map((image) => (
<Paper shadow='xs' p='sm' withBorder>
<Group justify='space-between' wrap='nowrap'>
<Stack>
<Text>
<strong>{image.title}</strong>
<br />
{image.description}
</Text>
</Stack>
<Button
size='sm'
variant='light'
onClick={image.action}
leftSection={image.icon}
>
{image.buttonText ?? <Trans>Act</Trans>}
</Button>
</Group>
</Paper>
));
return (
<SimpleGrid
cols={{
base: 1,
'600px': 2,
'1200px': 3
}}
type='container'
spacing='sm'
>
{slides}
</SimpleGrid>
);
}
export const QuickAction = () => {
const newUser = useCreateApiFormModal(userFields());
const newGroup = useCreateApiFormModal(groupFields());
const newProjectCode = useCreateApiFormModal({
url: ApiEndpoints.project_code_list,
title: t`Add Project Code`,
fields: projectCodeFields()
});
const newCustomState = useCreateApiFormModal({
url: ApiEndpoints.custom_state_list,
title: t`Add State`,
fields: useCustomStateFields()
});
const items = [
{
id: '0',
title: t`Open an Issue`,
description: t`Report a bug or request a feature on GitHub`,
icon: <IconBrandGithub />,
buttonText: t`Open Issue`,
action: () =>
window.open(
'https://github.com/inventree/inventree/issues/new',
'_blank'
)
},
{
id: '1',
title: t`Add New Group`,
description: t`Create a new group to manage your users`,
icon: <IconUsersGroup />,
buttonText: t`New Group`,
action: () => newGroup.open()
},
{
id: '2',
title: t`Add New User`,
description: t`Create a new user to manage your groups`,
icon: <IconUserPlus />,
buttonText: t`New User`,
action: () => newUser.open()
},
{
id: '3',
title: t`Add Project Code`,
description: t`Create a new project code to organize your items`,
icon: <IconListCheck />,
buttonText: t`Add Code`,
action: () => newProjectCode.open()
},
{
id: '4',
title: t`Add Custom State`,
description: t`Create a new custom state for your workflow`,
icon: <IconListCheck />,
buttonText: t`Add State`,
action: () => newCustomState.open()
}
];
return (
<Stack gap={'xs'} ml={'sm'}>
<ActionGrid items={items} />
{newUser.modal}
{newGroup.modal}
{newProjectCode.modal}
{newCustomState.modal}
</Stack>
);
};
@@ -3,22 +3,163 @@ import { AddItemButton } from '@lib/components/AddItemButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { Alert, Group, Paper, Tooltip } from '@mantine/core';
import {
ActionIcon,
Alert,
Divider,
Group,
HoverCard,
Loader,
Paper,
Stack,
Text,
Tooltip
} from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconShoppingCart } from '@tabler/icons-react';
import {
IconExclamationCircle,
IconInfoCircle,
IconShoppingCart
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSupplierPartFields } from '../../forms/CompanyForms';
import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import useWizard from '../../hooks/UseWizard';
import { RenderPartColumn } from '../../tables/ColumnRenderers';
import RemoveRowButton from '../buttons/RemoveRowButton';
import { StandaloneField } from '../forms/StandaloneField';
import Expand from '../items/Expand';
/**
* Render the "requirements" info for a part
* This fetches the information dynamically from the API
*/
function PartRequirementsInfo({
partId,
onQuantityChange
}: {
partId: number | string;
onQuantityChange?: (quantity: number) => void;
}) {
const [requiredQuantity, setRequiredQuantity] = useState<number>(0);
// Notify parent component of quantity change
useEffect(() => {
onQuantityChange?.(requiredQuantity);
}, [requiredQuantity]);
const requirements = useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: partId,
hasPrimaryKey: true,
defaultValue: {}
});
const widget = useMemo(() => {
if (
requirements.instanceQuery.isFetching ||
requirements.instanceQuery.isLoading
) {
return <Loader size='sm' />;
}
if (requirements.instanceQuery.isError) {
return (
<Tooltip label={t`Error fetching part requirements`}>
<ActionIcon variant='transparent' color='red'>
<IconExclamationCircle />
</ActionIcon>
</Tooltip>
);
}
// Calculate the total requirements
const buildRequirements =
requirements.instance?.required_for_build_orders || 0;
const salesRequirements =
requirements.instance?.required_for_sales_orders || 0;
const totalRequirements = buildRequirements + salesRequirements;
const building = requirements.instance?.building || 0;
const ordering = requirements.instance?.ordering || 0;
const incoming = building + ordering;
const inStock = requirements.instance?.total_stock || 0;
const required = Math.max(0, totalRequirements - inStock - incoming);
setRequiredQuantity(required);
return (
<HoverCard position='bottom-end'>
<HoverCard.Target>
<ActionIcon
variant='transparent'
color={required > 0 ? 'blue' : 'green'}
size='sm'
>
<IconInfoCircle />
</ActionIcon>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap='xs'>
<Text>{t`Requirements`}</Text>
<Divider />
{buildRequirements > 0 && (
<Group justify='space-between'>
<Text size='xs'>{t`Build Requirements`}</Text>
<Text size='xs'>{formatDecimal(buildRequirements)}</Text>
</Group>
)}
{salesRequirements > 0 && (
<Group justify='space-between'>
<Text size='xs'>{t`Sales Requirements`}</Text>
<Text size='xs'>{formatDecimal(salesRequirements)}</Text>
</Group>
)}
{inStock > 0 && (
<Group justify='space-between'>
<Text size='xs'>{t`In Stock`}</Text>
<Text size='xs'>{formatDecimal(inStock)}</Text>
</Group>
)}
{ordering > 0 && (
<Group justify='space-between'>
<Text size='xs'>{t`On Order`}</Text>
<Text size='xs'>{formatDecimal(ordering)}</Text>
</Group>
)}
{building > 0 && (
<Group justify='space-between'>
<Text size='xs'>{t`In Production`}</Text>
<Text size='xs'>{formatDecimal(building)}</Text>
</Group>
)}
<Group justify='space-between'>
<Text size='xs'>{t`Required Quantity`}</Text>
<Text size='xs'>{formatDecimal(required)}</Text>
</Group>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}, [
requirements.instanceQuery.isFetching,
requirements.instanceQuery.isLoading,
requirements.instanceQuery.isError,
requirements.instance,
setRequiredQuantity
]);
return widget;
}
/**
* Attributes for each selected part
* - part: The part instance
@@ -123,7 +264,10 @@ function SelectPartsStep({
width: '1%',
render: (record: PartOrderRecord) => (
<Group gap='xs' wrap='nowrap' justify='left'>
<RemoveRowButton onClick={() => onRemovePart(record.part)} />
<RemoveRowButton
tooltipAlignment={'top-start'}
onClick={() => onRemovePart(record.part)}
/>
</Group>
)
},
@@ -162,6 +306,7 @@ function SelectPartsStep({
filters: {
part: record.part.pk,
active: true,
part_detail: true,
supplier_detail: true
}
}}
@@ -169,7 +314,7 @@ function SelectPartsStep({
</Expand>
<AddItemButton
tooltip={t`New supplier part`}
tooltipAlignment='top'
tooltipAlignment='top-end'
onClick={() => {
setSelectedRecord(record);
newSupplierPart.open();
@@ -207,7 +352,7 @@ function SelectPartsStep({
</Expand>
<AddItemButton
tooltip={t`New purchase order`}
tooltipAlignment='top'
tooltipAlignment='top-end'
disabled={!record.supplier_part?.pk}
onClick={() => {
setSelectedRecord(record);
@@ -220,21 +365,29 @@ function SelectPartsStep({
{
accessor: 'quantity',
title: t`Quantity`,
width: 125,
width: 150,
render: (record: PartOrderRecord) => (
<StandaloneField
fieldName='quantity'
hideLabels={true}
error={record.errors?.quantity}
fieldDefinition={{
field_type: 'number',
required: true,
value: record.quantity,
onValueChange: (value) => {
onSelectQuantity(record.part.pk, value);
<Group gap='xs' wrap='nowrap'>
<StandaloneField
fieldName='quantity'
hideLabels={true}
error={record.errors?.quantity}
fieldDefinition={{
field_type: 'number',
required: true,
value: record.quantity,
onValueChange: (value) => {
onSelectQuantity(record.part.pk, value);
}
}}
/>
<PartRequirementsInfo
partId={record.part.pk}
onQuantityChange={(quantity: number) =>
onSelectQuantity(record.part.pk, quantity)
}
}}
/>
/>
</Group>
)
},
{
@@ -255,7 +408,7 @@ function SelectPartsStep({
}
icon={<IconShoppingCart />}
tooltip={t`Add to selected purchase order`}
tooltipAlignment='top'
tooltipAlignment='top-end'
color='blue'
/>
</Group>
+136 -63
View File
@@ -1,11 +1,22 @@
import { t } from '@lingui/core/macro';
import type { SpotlightActionData } from '@mantine/spotlight';
import { IconBarcode, IconLink, IconPointer } from '@tabler/icons-react';
import {
IconBarcode,
IconLink,
IconPointer,
IconSettings,
IconUserBolt,
IconUserCog
} from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { UserRoles } from '@lib/index';
import { openContextModal } from '@mantine/modals';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useLocalState } from '../states/LocalState';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { useUserState } from '../states/UserState';
import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links';
@@ -20,71 +31,133 @@ export function getActions(navigate: NavigateFunction) {
const setNavigationOpen = useLocalState(
useShallow((state) => state.setNavigationOpen)
);
const { user } = useUserState();
const globalSettings = useGlobalSettingsState();
const user = useUserState();
const actions: SpotlightActionData[] = [
{
id: 'dashboard',
label: t`Dashboard`,
description: t`Go to the InvenTree dashboard`,
onClick: () => navigate('/'),
leftSection: <IconLink size='1.2rem' />
},
{
id: 'documentation',
label: t`Documentation`,
description: t`Visit the documentation to learn more about InvenTree`,
onClick: () => {
window.location.href = docLinks.faq;
const actions: SpotlightActionData[] = useMemo(() => {
const _actions: SpotlightActionData[] = [
{
id: 'dashboard',
label: t`Dashboard`,
description: t`Go to the InvenTree dashboard`,
onClick: () => navigate('/'),
leftSection: <IconLink size='1.2rem' />
},
leftSection: <IconLink size='1.2rem' />
},
{
id: 'about',
label: t`About InvenTree`,
description: t`About the InvenTree org`,
onClick: () => aboutInvenTree(),
leftSection: <IconLink size='1.2rem' />
},
{
id: 'server-info',
label: t`Server Information`,
description: t`About this InvenTree instance`,
onClick: () => serverInfo(),
leftSection: <IconLink size='1.2rem' />
},
{
id: 'license-info',
label: t`License Information`,
description: t`Licenses for dependencies of the service`,
onClick: () => licenseInfo(),
leftSection: <IconLink size='1.2rem' />
},
{
id: 'navigation',
label: t`Open Navigation`,
description: t`Open the main navigation menu`,
onClick: () => setNavigationOpen(true),
leftSection: <IconPointer size='1.2rem' />
},
{
id: 'scan',
label: t`Scan`,
description: t`Scan a barcode or QR code`,
onClick: () => openQrModal(navigate),
leftSection: <IconBarcode size='1.2rem' />
}
];
{
id: 'documentation',
label: t`Documentation`,
description: t`Visit the documentation to learn more about InvenTree`,
onClick: () => {
window.location.href = docLinks.faq;
},
leftSection: <IconLink size='1.2rem' />
},
{
id: 'about',
label: t`About InvenTree`,
description: t`About the InvenTree org`,
onClick: () => aboutInvenTree(),
leftSection: <IconLink size='1.2rem' />
},
{
id: 'server-info',
label: t`Server Information`,
description: t`About this InvenTree instance`,
onClick: () => serverInfo(),
leftSection: <IconLink size='1.2rem' />
},
{
id: 'license-info',
label: t`License Information`,
description: t`Licenses for dependencies of the service`,
onClick: () => licenseInfo(),
leftSection: <IconLink size='1.2rem' />
},
{
id: 'navigation',
label: t`Open Navigation`,
description: t`Open the main navigation menu`,
onClick: () => setNavigationOpen(true),
leftSection: <IconPointer size='1.2rem' />
},
{
id: 'scan',
label: t`Scan`,
description: t`Scan a barcode or QR code`,
onClick: () => openQrModal(navigate),
leftSection: <IconBarcode size='1.2rem' />
},
{
id: 'user-settings',
label: t`User Settings`,
// Staff actions
user?.is_staff &&
actions.push({
id: 'admin-center',
label: t`Admin Center`,
description: t`Go to the Admin Center`,
onClick: () => {}, /// navigate(menuItems['settings-admin'].link),}
leftSection: <IconLink size='1.2rem' />
});
description: t`Go to your user settings`,
onClick: () => navigate('/settings/user'),
leftSection: <IconUserCog size='1.2rem' />
}
];
// Page Actions
user?.hasViewRole(UserRoles.purchase_order) &&
_actions.push({
id: 'purchase-orders',
label: t`Purchase Orders`,
description: t`Go to Purchase Orders`,
onClick: () =>
navigate(ModelInformationDict['purchaseorder'].url_overview!),
leftSection: <IconLink size='1.2rem' />
});
user?.hasViewRole(UserRoles.sales_order) &&
_actions.push({
id: 'sales-orders',
label: t`Sales Orders`,
description: t`Go to Sales Orders`,
onClick: () =>
navigate(ModelInformationDict['salesorder'].url_overview!),
leftSection: <IconLink size='1.2rem' />
});
globalSettings.isSet('RETURNORDER_ENABLED') &&
user?.hasViewRole(UserRoles.return_order) &&
_actions.push({
id: 'return-orders',
label: t`Return Orders`,
description: t`Go to Return Orders`,
onClick: () =>
navigate(ModelInformationDict['returnorder'].url_overview!),
leftSection: <IconLink size='1.2rem' />
});
user?.hasViewRole(UserRoles.build) &&
_actions.push({
id: 'builds',
label: t`Build Orders`,
description: t`Go to Build Orders`,
onClick: () => navigate(ModelInformationDict['build'].url_overview!),
leftSection: <IconLink size='1.2rem' />
});
user?.isStaff() &&
_actions.push({
id: 'system-settings',
label: t`System Settings`,
description: t`Go to System Settings`,
onClick: () => navigate('/settings/system'),
leftSection: <IconSettings size='1.2rem' />
});
user?.isStaff() &&
_actions.push({
id: 'admin-center',
label: t`Admin Center`,
description: t`Go to the Admin Center`,
onClick: () => {}, /// navigate(menuItems['settings-admin'].link),}
leftSection: <IconUserBolt size='1.2rem' />
});
return _actions;
}, [navigate, setNavigationOpen, globalSettings, user]);
return actions;
}
+36 -3
View File
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { IconPackages } from '@tabler/icons-react';
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
@@ -12,8 +12,10 @@ import { useGlobalSettingsState } from '../states/SettingsStates';
* Construct a set of fields for creating / editing a Part instance
*/
export function usePartFields({
create = false
create = false,
duplicatePartInstance
}: {
duplicatePartInstance?: any;
create?: boolean;
}): ApiFormFieldSet {
const settings = useGlobalSettingsState();
@@ -89,6 +91,7 @@ export function usePartFields({
};
fields.initial_supplier = {
icon: <IconBuildingStore />,
children: {
supplier: {
filters: {
@@ -106,6 +109,36 @@ export function usePartFields({
};
}
// Additional fields for part duplication
if (create && duplicatePartInstance?.pk) {
fields.duplicate = {
icon: <IconCopy />,
children: {
part: {
value: duplicatePartInstance?.pk,
hidden: true
},
copy_image: {
value: true
},
copy_bom: {
value: settings.isSet('PART_COPY_BOM'),
hidden: !duplicatePartInstance?.assembly
},
copy_notes: {
value: true
},
copy_parameters: {
value: settings.isSet('PART_COPY_PARAMETERS')
},
copy_tests: {
value: true,
hidden: !duplicatePartInstance?.testable
}
}
};
}
if (settings.isSet('PART_REVISION_ASSEMBLY_ONLY')) {
fields.revision_of.filters['assembly'] = true;
}
@@ -126,7 +159,7 @@ export function usePartFields({
}
return fields;
}, [create, settings]);
}, [create, duplicatePartInstance, settings]);
}
/**
+10 -6
View File
@@ -506,7 +506,7 @@ function LineItemFormRow({
icon={<InvenTreeIcon icon='location' />}
tooltip={t`Set Location`}
tooltipAlignment='top'
variant={locationOpen ? 'filled' : 'transparent'}
variant={locationOpen ? 'outline' : 'transparent'}
/>
<ActionButton
size='sm'
@@ -514,7 +514,7 @@ function LineItemFormRow({
icon={<InvenTreeIcon icon='batch_code' />}
tooltip={batchToolTip}
tooltipAlignment='top'
variant={batchOpen ? 'filled' : 'transparent'}
variant={batchOpen ? 'outline' : 'transparent'}
/>
{settings.isSet('STOCK_ENABLE_EXPIRY') && (
<ActionButton
@@ -523,7 +523,7 @@ function LineItemFormRow({
icon={<IconCalendarExclamation />}
tooltip={t`Set Expiry Date`}
tooltipAlignment='top'
variant={expiryDateOpen ? 'filled' : 'transparent'}
variant={expiryDateOpen ? 'outline' : 'transparent'}
/>
)}
<ActionButton
@@ -532,20 +532,20 @@ function LineItemFormRow({
tooltip={t`Adjust Packaging`}
tooltipAlignment='top'
onClick={() => packagingHandlers.toggle()}
variant={packagingOpen ? 'filled' : 'transparent'}
variant={packagingOpen ? 'outline' : 'transparent'}
/>
<ActionButton
onClick={() => statusHandlers.toggle()}
icon={<InvenTreeIcon icon='status' />}
tooltip={t`Change Status`}
tooltipAlignment='top'
variant={statusOpen ? 'filled' : 'transparent'}
variant={statusOpen ? 'outline' : 'transparent'}
/>
<ActionButton
icon={<InvenTreeIcon icon='note' />}
tooltip={t`Add Note`}
tooltipAlignment='top'
variant={noteOpen ? 'filled' : 'transparent'}
variant={noteOpen ? 'outline' : 'transparent'}
onClick={() => noteHandlers.toggle()}
/>
{barcode ? (
@@ -781,6 +781,10 @@ export function useReceiveLineItems(props: LineItemsForm) {
modelRenderer: (row: TableFieldRowProps) => {
const record = records[row.item.line_item];
if (!record) {
return null;
}
return (
<LineItemFormRow
props={row}
@@ -1,11 +1,9 @@
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { Table } from '@mantine/core';
import { useMemo } from 'react';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
function BuildAllocateLineRow({
@@ -62,7 +60,7 @@ function BuildAllocateLineRow({
}, [props]);
return (
<Table.Tr key={`table-row-${props.item.pk}`}>
<Table.Tr key={`table-row-${props.item.id ?? props.idx}`}>
<Table.Td>
<StandaloneField fieldName='value' fieldDefinition={valueField} />
</Table.Td>
+1 -1
View File
@@ -693,7 +693,7 @@ export async function handleWebauthnLogin(
) {
const { setAuthContext } = useServerApiState.getState();
const webauthn_challenge = api
const webauthn_challenge = await api
.get(apiUrl(ApiEndpoints.auth_webauthn_login))
.catch(() => {})
.then((response) => {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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