mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-06 23:21:59 +00:00
Merge branch 'master' into app-stock-coverage
This commit is contained in:
@@ -1,13 +1,25 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 249
|
||||
INVENTREE_API_VERSION = 253
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v253 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7944
|
||||
- Adjustments for user API endpoints
|
||||
|
||||
v252 - 2024-09-13 : https://github.com/inventree/InvenTree/pull/8040
|
||||
- Add endpoint for listing all known units
|
||||
|
||||
v251 - 2024-09-06 : https://github.com/inventree/InvenTree/pull/8018
|
||||
- Adds "attach_to_model" field to the ReporTemplate model
|
||||
|
||||
v250 - 2024-09-04 : https://github.com/inventree/InvenTree/pull/8069
|
||||
- Fixes 'revision' field definition in Part serializer
|
||||
|
||||
v249 - 2024-08-23 : https://github.com/inventree/InvenTree/pull/7978
|
||||
- Sort status enums
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import logging
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404
|
||||
|
||||
from rest_framework import exceptions, serializers
|
||||
from rest_framework.fields import empty
|
||||
from rest_framework.metadata import SimpleMetadata
|
||||
from rest_framework.request import clone_request
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
import common.models
|
||||
@@ -29,6 +33,40 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
so we can perform lookup for ForeignKey related fields.
|
||||
"""
|
||||
|
||||
def determine_actions(self, request, view):
|
||||
"""Determine the 'actions' available to the user for the given view.
|
||||
|
||||
Note that this differs from the standard DRF implementation,
|
||||
in that we also allow annotation for the 'GET' method.
|
||||
|
||||
This allows the client to determine what fields are available,
|
||||
even if they are only for a read (GET) operation.
|
||||
|
||||
See SimpleMetadata.determine_actions for more information.
|
||||
"""
|
||||
actions = {}
|
||||
|
||||
for method in {'PUT', 'POST', 'GET'} & set(view.allowed_methods):
|
||||
view.request = clone_request(request, method)
|
||||
try:
|
||||
# Test global permissions
|
||||
if hasattr(view, 'check_permissions'):
|
||||
view.check_permissions(view.request)
|
||||
# Test object permissions
|
||||
if method == 'PUT' and hasattr(view, 'get_object'):
|
||||
view.get_object()
|
||||
except (exceptions.APIException, PermissionDenied, Http404):
|
||||
pass
|
||||
else:
|
||||
# If user has appropriate permissions for the view, include
|
||||
# appropriate metadata about the fields that should be supplied.
|
||||
serializer = view.get_serializer()
|
||||
actions[method] = self.get_serializer_info(serializer)
|
||||
finally:
|
||||
view.request = request
|
||||
|
||||
return actions
|
||||
|
||||
def determine_metadata(self, request, view):
|
||||
"""Overwrite the metadata to adapt to the request user."""
|
||||
self.request = request
|
||||
@@ -81,6 +119,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
# Map the request method to a permission type
|
||||
rolemap = {
|
||||
'GET': 'view',
|
||||
'POST': 'add',
|
||||
'PUT': 'change',
|
||||
'PATCH': 'change',
|
||||
@@ -102,10 +141,6 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
|
||||
actions['DELETE'] = {}
|
||||
|
||||
# Add a 'VIEW' action if we are allowed to view
|
||||
if 'GET' in view.allowed_methods and check(user, table, 'view'):
|
||||
actions['GET'] = {}
|
||||
|
||||
metadata['actions'] = actions
|
||||
|
||||
except AttributeError:
|
||||
|
||||
@@ -79,6 +79,9 @@ class RolePermission(permissions.BasePermission):
|
||||
# Extract the model name associated with this request
|
||||
model = get_model_for_view(view)
|
||||
|
||||
if model is None:
|
||||
return True
|
||||
|
||||
app_label = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
|
||||
@@ -99,6 +102,17 @@ class IsSuperuser(permissions.IsAdminUser):
|
||||
return bool(request.user and request.user.is_superuser)
|
||||
|
||||
|
||||
class IsSuperuserOrReadOnly(permissions.IsAdminUser):
|
||||
"""Allow read-only access to any user, but write access is restricted to superuser users."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if the user is a superuser."""
|
||||
return bool(
|
||||
(request.user and request.user.is_superuser)
|
||||
or request.method in permissions.SAFE_METHODS
|
||||
)
|
||||
|
||||
|
||||
class IsStaffOrReadOnly(permissions.IsAdminUser):
|
||||
"""Allows read-only access to any user, but write access is restricted to staff users."""
|
||||
|
||||
|
||||
@@ -403,18 +403,21 @@ class UserSerializer(InvenTreeModelSerializer):
|
||||
read_only_fields = ['username', 'email']
|
||||
|
||||
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
|
||||
|
||||
first_name = serializers.CharField(
|
||||
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
|
||||
)
|
||||
|
||||
last_name = serializers.CharField(
|
||||
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
|
||||
)
|
||||
|
||||
email = serializers.EmailField(
|
||||
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
|
||||
)
|
||||
|
||||
|
||||
class ExendedUserSerializer(UserSerializer):
|
||||
class ExtendedUserSerializer(UserSerializer):
|
||||
"""Serializer for a User with a bit more info."""
|
||||
|
||||
from users.serializers import GroupSerializer
|
||||
@@ -437,9 +440,11 @@ class ExendedUserSerializer(UserSerializer):
|
||||
is_staff = serializers.BooleanField(
|
||||
label=_('Staff'), help_text=_('Does this user have staff permissions')
|
||||
)
|
||||
|
||||
is_superuser = serializers.BooleanField(
|
||||
label=_('Superuser'), help_text=_('Is this user a superuser')
|
||||
)
|
||||
|
||||
is_active = serializers.BooleanField(
|
||||
label=_('Active'), help_text=_('Is this user account active')
|
||||
)
|
||||
@@ -464,9 +469,33 @@ class ExendedUserSerializer(UserSerializer):
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
class UserCreateSerializer(ExendedUserSerializer):
|
||||
class MeUserSerializer(ExtendedUserSerializer):
|
||||
"""API serializer specifically for the 'me' endpoint."""
|
||||
|
||||
class Meta(ExtendedUserSerializer.Meta):
|
||||
"""Metaclass options.
|
||||
|
||||
Extends the ExtendedUserSerializer.Meta options,
|
||||
but ensures that certain fields are read-only.
|
||||
"""
|
||||
|
||||
read_only_fields = [
|
||||
*ExtendedUserSerializer.Meta.read_only_fields,
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
]
|
||||
|
||||
|
||||
class UserCreateSerializer(ExtendedUserSerializer):
|
||||
"""Serializer for creating a new User."""
|
||||
|
||||
class Meta(ExtendedUserSerializer.Meta):
|
||||
"""Metaclass options for the UserCreateSerializer."""
|
||||
|
||||
# Prevent creation of users with superuser or staff permissions
|
||||
read_only_fields = ['groups', 'is_staff', 'is_superuser']
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Expanded valiadation for auth."""
|
||||
# Check that the user trying to create a new user is a superuser
|
||||
|
||||
@@ -1280,6 +1280,9 @@ PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
# Flag to allow table events during testing
|
||||
TESTING_TABLE_EVENTS = False
|
||||
|
||||
# Flag to allow pricing recalculations during testing
|
||||
TESTING_PRICING = False
|
||||
|
||||
# User interface customization values
|
||||
CUSTOM_LOGO = get_custom_file(
|
||||
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
|
||||
|
||||
@@ -263,7 +263,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
MAX_QUERY_TIME = 7.5
|
||||
|
||||
@contextmanager
|
||||
def assertNumQueriesLessThan(self, value, using='default', verbose=None, url=None):
|
||||
def assertNumQueriesLessThan(self, value, using='default', verbose=False, url=None):
|
||||
"""Context manager to check that the number of queries is less than a certain value.
|
||||
|
||||
Example:
|
||||
@@ -281,7 +281,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
|
||||
) # pragma: no cover
|
||||
|
||||
if verbose or n >= value:
|
||||
if verbose and n >= value:
|
||||
msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover
|
||||
else:
|
||||
msg = None
|
||||
|
||||
@@ -1039,6 +1039,12 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
outputs = cls.build.build_outputs.all()
|
||||
cls.build.complete_build_output(outputs[0], cls.user)
|
||||
|
||||
def setUp(self):
|
||||
"""Basic operation as part of test suite setup"""
|
||||
super().setUp()
|
||||
|
||||
self.generate_exchange_rates()
|
||||
|
||||
def test_setup(self):
|
||||
"""Validate expected state after set-up."""
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
||||
@@ -1067,7 +1073,7 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
'accept_overallocated': 'accept',
|
||||
},
|
||||
expected_code=201,
|
||||
max_query_count=550, # TODO: Come back and refactor this
|
||||
max_query_count=1000, # TODO: Come back and refactor this
|
||||
)
|
||||
|
||||
self.build.refresh_from_db()
|
||||
@@ -1088,9 +1094,11 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
'accept_overallocated': 'trim',
|
||||
},
|
||||
expected_code=201,
|
||||
max_query_count=600, # TODO: Come back and refactor this
|
||||
max_query_count=1000, # TODO: Come back and refactor this
|
||||
)
|
||||
|
||||
# Note: Large number of queries is due to pricing recalculation for each stock item
|
||||
|
||||
self.build.refresh_from_db()
|
||||
|
||||
# Build should have been marked as complete
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Provides a JSON API for common components."""
|
||||
|
||||
import json
|
||||
from typing import Type
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -19,6 +20,7 @@ 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 pint._typing import UnitLike
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
@@ -27,6 +29,7 @@ from rest_framework.views import APIView
|
||||
|
||||
import common.models
|
||||
import common.serializers
|
||||
import InvenTree.conversion
|
||||
from common.icons import get_icon_packs
|
||||
from common.settings import get_global_setting
|
||||
from generic.states.api import urlpattern as generic_states_api_urls
|
||||
@@ -533,6 +536,36 @@ class CustomUnitDetail(RetrieveUpdateDestroyAPI):
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
|
||||
|
||||
class AllUnitList(ListAPI):
|
||||
"""List of all defined units."""
|
||||
|
||||
serializer_class = common.serializers.AllUnitListResponseSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Return a list of all available units."""
|
||||
reg = InvenTree.conversion.get_unit_registry()
|
||||
all_units = {k: self.get_unit(reg, k) for k in reg}
|
||||
data = {
|
||||
'default_system': reg.default_system,
|
||||
'available_systems': dir(reg.sys),
|
||||
'available_units': {k: v for k, v in all_units.items() if v},
|
||||
}
|
||||
return Response(data)
|
||||
|
||||
def get_unit(self, reg, k):
|
||||
"""Parse a unit from the registry."""
|
||||
if not hasattr(reg, k):
|
||||
return None
|
||||
unit: Type[UnitLike] = getattr(reg, k)
|
||||
return {
|
||||
'name': k,
|
||||
'is_alias': reg.get_name(k) == k,
|
||||
'compatible_units': [str(a) for a in unit.compatible_units()],
|
||||
'isdimensionless': unit.dimensionless,
|
||||
}
|
||||
|
||||
|
||||
class ErrorMessageList(BulkDeleteMixin, ListAPI):
|
||||
"""List view for server error messages."""
|
||||
|
||||
@@ -900,6 +933,7 @@ common_api_urls = [
|
||||
path('', CustomUnitDetail.as_view(), name='api-custom-unit-detail')
|
||||
]),
|
||||
),
|
||||
path('all/', AllUnitList.as_view(), name='api-all-unit-list'),
|
||||
path('', CustomUnitList.as_view(), name='api-custom-unit-list'),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -1704,20 +1704,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': 'A4',
|
||||
'choices': report.helpers.report_page_size_options,
|
||||
},
|
||||
'REPORT_ENABLE_TEST_REPORT': {
|
||||
'name': _('Enable Test Reports'),
|
||||
'description': _('Enable generation of test reports'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'REPORT_ATTACH_TEST_REPORT': {
|
||||
'name': _('Attach Test Reports'),
|
||||
'description': _(
|
||||
'When printing a Test Report, attach a copy of the Test Report to the associated Stock Item'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'SERIAL_NUMBER_GLOBALLY_UNIQUE': {
|
||||
'name': _('Globally Unique Serials'),
|
||||
'description': _('Serial numbers for stock items must be globally unique'),
|
||||
@@ -2160,6 +2146,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'TEST_UPLOAD_CREATE_TEMPLATE': {
|
||||
'name': _('Create Template on Upload'),
|
||||
'description': _(
|
||||
'Create a new test template when uploading test data which does not match an existing template'
|
||||
),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
}
|
||||
|
||||
typ = 'inventree'
|
||||
|
||||
@@ -382,6 +382,22 @@ class CustomUnitSerializer(DataImportExportSerializerMixin, InvenTreeModelSerial
|
||||
fields = ['pk', 'name', 'symbol', 'definition']
|
||||
|
||||
|
||||
class AllUnitListResponseSerializer(serializers.Serializer):
|
||||
"""Serializer for the AllUnitList."""
|
||||
|
||||
class Unit(serializers.Serializer):
|
||||
"""Serializer for the AllUnitListResponseSerializer."""
|
||||
|
||||
name = serializers.CharField()
|
||||
is_alias = serializers.BooleanField()
|
||||
compatible_units = serializers.ListField(child=serializers.CharField())
|
||||
isdimensionless = serializers.BooleanField()
|
||||
|
||||
default_system = serializers.CharField()
|
||||
available_systems = serializers.ListField(child=serializers.CharField())
|
||||
available_units = Unit(many=True)
|
||||
|
||||
|
||||
class ErrorMessageSerializer(InvenTreeModelSerializer):
|
||||
"""DRF serializer for server error messages."""
|
||||
|
||||
|
||||
@@ -233,9 +233,6 @@ class SettingsTest(InvenTreeTestCase):
|
||||
report_size_obj = InvenTreeSetting.get_setting_object(
|
||||
'REPORT_DEFAULT_PAGE_SIZE'
|
||||
)
|
||||
report_test_obj = InvenTreeSetting.get_setting_object(
|
||||
'REPORT_ENABLE_TEST_REPORT'
|
||||
)
|
||||
|
||||
# check settings base fields
|
||||
self.assertEqual(instance_obj.name, 'Server Instance Name')
|
||||
@@ -265,7 +262,6 @@ class SettingsTest(InvenTreeTestCase):
|
||||
|
||||
# check setting_type
|
||||
self.assertEqual(instance_obj.setting_type(), 'string')
|
||||
self.assertEqual(report_test_obj.setting_type(), 'boolean')
|
||||
self.assertEqual(stale_days.setting_type(), 'integer')
|
||||
|
||||
# check as_int
|
||||
@@ -274,9 +270,6 @@ class SettingsTest(InvenTreeTestCase):
|
||||
instance_obj.as_int(), 'InvenTree'
|
||||
) # not an int -> return default
|
||||
|
||||
# check as_bool
|
||||
self.assertEqual(report_test_obj.as_bool(), True)
|
||||
|
||||
# check to_native_value
|
||||
self.assertEqual(stale_days.to_native_value(), 0)
|
||||
|
||||
@@ -1507,6 +1500,14 @@ class CustomUnitAPITest(InvenTreeAPITestCase):
|
||||
for name in invalid_name_values:
|
||||
self.patch(url, {'name': name}, expected_code=400)
|
||||
|
||||
def test_api(self):
|
||||
"""Test the CustomUnit API."""
|
||||
response = self.get(reverse('api-all-unit-list'))
|
||||
self.assertIn('default_system', response.data)
|
||||
self.assertIn('available_systems', response.data)
|
||||
self.assertIn('available_units', response.data)
|
||||
self.assertEqual(len(response.data['available_units']) > 100, True)
|
||||
|
||||
|
||||
class ContentTypeAPITest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the ContentType API."""
|
||||
|
||||
@@ -1049,7 +1049,10 @@ class SupplierPriceBreak(common.models.PriceBreak):
|
||||
)
|
||||
def after_save_supplier_price(sender, instance, created, **kwargs):
|
||||
"""Callback function when a SupplierPriceBreak is created or updated."""
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
if (
|
||||
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
|
||||
and not InvenTree.ready.isImportingData()
|
||||
):
|
||||
if instance.part and instance.part.part:
|
||||
instance.part.part.schedule_pricing_update(create=True)
|
||||
|
||||
@@ -1061,6 +1064,9 @@ def after_save_supplier_price(sender, instance, created, **kwargs):
|
||||
)
|
||||
def after_delete_supplier_price(sender, instance, **kwargs):
|
||||
"""Callback function when a SupplierPriceBreak is deleted."""
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
if (
|
||||
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
|
||||
and not InvenTree.ready.isImportingData()
|
||||
):
|
||||
if instance.part and instance.part.part:
|
||||
instance.part.part.schedule_pricing_update(create=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
@@ -1517,37 +1517,45 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError({'serial_numbers': e.messages})
|
||||
|
||||
serials_not_exist = []
|
||||
serials_allocated = []
|
||||
serials_not_exist = set()
|
||||
serials_unavailable = set()
|
||||
stock_items_to_allocate = []
|
||||
|
||||
for serial in data['serials']:
|
||||
serial = str(serial).strip()
|
||||
|
||||
items = stock.models.StockItem.objects.filter(
|
||||
part=part, serial=serial, quantity=1
|
||||
)
|
||||
|
||||
if not items.exists():
|
||||
serials_not_exist.append(str(serial))
|
||||
serials_not_exist.add(str(serial))
|
||||
continue
|
||||
|
||||
stock_item = items[0]
|
||||
|
||||
if stock_item.unallocated_quantity() == 1:
|
||||
stock_items_to_allocate.append(stock_item)
|
||||
else:
|
||||
serials_allocated.append(str(serial))
|
||||
if not stock_item.in_stock:
|
||||
serials_unavailable.add(str(serial))
|
||||
continue
|
||||
|
||||
if stock_item.unallocated_quantity() < 1:
|
||||
serials_unavailable.add(str(serial))
|
||||
continue
|
||||
|
||||
# At this point, the serial number is valid, and can be added to the list
|
||||
stock_items_to_allocate.append(stock_item)
|
||||
|
||||
if len(serials_not_exist) > 0:
|
||||
error_msg = _('No match found for the following serial numbers')
|
||||
error_msg += ': '
|
||||
error_msg += ','.join(serials_not_exist)
|
||||
error_msg += ','.join(sorted(serials_not_exist))
|
||||
|
||||
raise ValidationError({'serial_numbers': error_msg})
|
||||
|
||||
if len(serials_allocated) > 0:
|
||||
error_msg = _('The following serial numbers are already allocated')
|
||||
if len(serials_unavailable) > 0:
|
||||
error_msg = _('The following serial numbers are unavailable')
|
||||
error_msg += ': '
|
||||
error_msg += ','.join(serials_allocated)
|
||||
error_msg += ','.join(sorted(serials_unavailable))
|
||||
|
||||
raise ValidationError({'serial_numbers': error_msg})
|
||||
|
||||
|
||||
@@ -1949,7 +1949,7 @@ class Part(
|
||||
|
||||
return pricing
|
||||
|
||||
def schedule_pricing_update(self, create: bool = False, test: bool = False):
|
||||
def schedule_pricing_update(self, create: bool = False):
|
||||
"""Helper function to schedule a pricing update.
|
||||
|
||||
Importantly, catches any errors which may occur during deletion of related objects,
|
||||
@@ -1959,7 +1959,6 @@ class Part(
|
||||
|
||||
Arguments:
|
||||
create: Whether or not a new PartPricing object should be created if it does not already exist
|
||||
test: Whether or not the pricing update is allowed during unit tests
|
||||
"""
|
||||
try:
|
||||
self.refresh_from_db()
|
||||
@@ -1970,7 +1969,7 @@ class Part(
|
||||
pricing = self.pricing
|
||||
|
||||
if create or pricing.pk:
|
||||
pricing.schedule_for_update(test=test)
|
||||
pricing.schedule_for_update()
|
||||
except IntegrityError:
|
||||
# If this part instance has been deleted,
|
||||
# some post-delete or post-save signals may still be fired
|
||||
@@ -2550,7 +2549,8 @@ class PartPricing(common.models.MetaMixin):
|
||||
- Detailed pricing information is very context specific in any case
|
||||
"""
|
||||
|
||||
price_modified = False
|
||||
# When calculating assembly pricing, we limit the depth of the calculation
|
||||
MAX_PRICING_DEPTH = 50
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
@@ -2579,14 +2579,10 @@ class PartPricing(common.models.MetaMixin):
|
||||
|
||||
return result
|
||||
|
||||
def schedule_for_update(self, counter: int = 0, test: bool = False):
|
||||
def schedule_for_update(self, counter: int = 0):
|
||||
"""Schedule this pricing to be updated."""
|
||||
import InvenTree.ready
|
||||
|
||||
# If we are running within CI, only schedule the update if the test flag is set
|
||||
if settings.TESTING and not test:
|
||||
return
|
||||
|
||||
# If importing data, skip pricing update
|
||||
if InvenTree.ready.isImportingData():
|
||||
return
|
||||
@@ -2630,7 +2626,7 @@ class PartPricing(common.models.MetaMixin):
|
||||
logger.debug('Pricing for %s already scheduled for update - skipping', p)
|
||||
return
|
||||
|
||||
if counter > 25:
|
||||
if counter > self.MAX_PRICING_DEPTH:
|
||||
# Prevent infinite recursion / stack depth issues
|
||||
logger.debug(
|
||||
counter, f'Skipping pricing update for {p} - maximum depth exceeded'
|
||||
@@ -2649,16 +2645,36 @@ class PartPricing(common.models.MetaMixin):
|
||||
|
||||
import part.tasks as part_tasks
|
||||
|
||||
# Pricing calculations are performed in the background,
|
||||
# unless the TESTING_PRICING flag is set
|
||||
background = not settings.TESTING or not settings.TESTING_PRICING
|
||||
|
||||
# Offload task to update the pricing
|
||||
# Force async, to prevent running in the foreground
|
||||
# Force async, to prevent running in the foreground (unless in testing mode)
|
||||
InvenTree.tasks.offload_task(
|
||||
part_tasks.update_part_pricing, self, counter=counter, force_async=True
|
||||
part_tasks.update_part_pricing,
|
||||
self,
|
||||
counter=counter,
|
||||
force_async=background,
|
||||
)
|
||||
|
||||
def update_pricing(self, counter: int = 0, cascade: bool = True):
|
||||
"""Recalculate all cost data for the referenced Part instance."""
|
||||
# If importing data, skip pricing update
|
||||
def update_pricing(
|
||||
self,
|
||||
counter: int = 0,
|
||||
cascade: bool = True,
|
||||
previous_min=None,
|
||||
previous_max=None,
|
||||
):
|
||||
"""Recalculate all cost data for the referenced Part instance.
|
||||
|
||||
Arguments:
|
||||
counter: Recursion counter (used to prevent infinite recursion)
|
||||
cascade: If True, update pricing for all assemblies and templates which use this part
|
||||
previous_min: Previous minimum price (used to prevent further updates if unchanged)
|
||||
previous_max: Previous maximum price (used to prevent further updates if unchanged)
|
||||
|
||||
"""
|
||||
# If importing data, skip pricing update
|
||||
if InvenTree.ready.isImportingData():
|
||||
return
|
||||
|
||||
@@ -2689,18 +2705,25 @@ class PartPricing(common.models.MetaMixin):
|
||||
# Background worker processes may try to concurrently update
|
||||
pass
|
||||
|
||||
pricing_changed = False
|
||||
|
||||
# Without previous pricing data, we assume that the pricing has changed
|
||||
if previous_min != self.overall_min or previous_max != self.overall_max:
|
||||
pricing_changed = True
|
||||
|
||||
# Update parent assemblies and templates
|
||||
if cascade and self.price_modified:
|
||||
if pricing_changed and cascade:
|
||||
self.update_assemblies(counter)
|
||||
self.update_templates(counter)
|
||||
|
||||
def update_assemblies(self, counter: int = 0):
|
||||
"""Schedule updates for any assemblies which use this part."""
|
||||
# If the linked Part is used in any assemblies, schedule a pricing update for those assemblies
|
||||
|
||||
used_in_parts = self.part.get_used_in()
|
||||
|
||||
for p in used_in_parts:
|
||||
p.pricing.schedule_for_update(counter + 1)
|
||||
p.pricing.schedule_for_update(counter=counter + 1)
|
||||
|
||||
def update_templates(self, counter: int = 0):
|
||||
"""Schedule updates for any template parts above this part."""
|
||||
@@ -2716,13 +2739,13 @@ class PartPricing(common.models.MetaMixin):
|
||||
|
||||
try:
|
||||
self.update_overall_cost()
|
||||
except IntegrityError:
|
||||
except Exception:
|
||||
# If something has happened to the Part model, might throw an error
|
||||
pass
|
||||
|
||||
try:
|
||||
super().save(*args, **kwargs)
|
||||
except IntegrityError:
|
||||
except Exception:
|
||||
# This error may be thrown if there is already duplicate pricing data
|
||||
pass
|
||||
|
||||
@@ -2790,9 +2813,6 @@ class PartPricing(common.models.MetaMixin):
|
||||
|
||||
any_max_elements = True
|
||||
|
||||
old_bom_cost_min = self.bom_cost_min
|
||||
old_bom_cost_max = self.bom_cost_max
|
||||
|
||||
if any_min_elements:
|
||||
self.bom_cost_min = cumulative_min
|
||||
else:
|
||||
@@ -2803,12 +2823,6 @@ class PartPricing(common.models.MetaMixin):
|
||||
else:
|
||||
self.bom_cost_max = None
|
||||
|
||||
if (
|
||||
old_bom_cost_min != self.bom_cost_min
|
||||
or old_bom_cost_max != self.bom_cost_max
|
||||
):
|
||||
self.price_modified = True
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
@@ -2872,12 +2886,6 @@ class PartPricing(common.models.MetaMixin):
|
||||
if purchase_max is None or cost > purchase_max:
|
||||
purchase_max = cost
|
||||
|
||||
if (
|
||||
self.purchase_cost_min != purchase_min
|
||||
or self.purchase_cost_max != purchase_max
|
||||
):
|
||||
self.price_modified = True
|
||||
|
||||
self.purchase_cost_min = purchase_min
|
||||
self.purchase_cost_max = purchase_max
|
||||
|
||||
@@ -2904,12 +2912,6 @@ class PartPricing(common.models.MetaMixin):
|
||||
if max_int_cost is None or cost > max_int_cost:
|
||||
max_int_cost = cost
|
||||
|
||||
if (
|
||||
self.internal_cost_min != min_int_cost
|
||||
or self.internal_cost_max != max_int_cost
|
||||
):
|
||||
self.price_modified = True
|
||||
|
||||
self.internal_cost_min = min_int_cost
|
||||
self.internal_cost_max = max_int_cost
|
||||
|
||||
@@ -2945,12 +2947,6 @@ class PartPricing(common.models.MetaMixin):
|
||||
if max_sup_cost is None or cost > max_sup_cost:
|
||||
max_sup_cost = cost
|
||||
|
||||
if (
|
||||
self.supplier_price_min != min_sup_cost
|
||||
or self.supplier_price_max != max_sup_cost
|
||||
):
|
||||
self.price_modified = True
|
||||
|
||||
self.supplier_price_min = min_sup_cost
|
||||
self.supplier_price_max = max_sup_cost
|
||||
|
||||
@@ -2986,9 +2982,6 @@ class PartPricing(common.models.MetaMixin):
|
||||
if variant_max is None or v_max > variant_max:
|
||||
variant_max = v_max
|
||||
|
||||
if self.variant_cost_min != variant_min or self.variant_cost_max != variant_max:
|
||||
self.price_modified = True
|
||||
|
||||
self.variant_cost_min = variant_min
|
||||
self.variant_cost_max = variant_max
|
||||
|
||||
@@ -3109,12 +3102,6 @@ class PartPricing(common.models.MetaMixin):
|
||||
if max_sell_history is None or cost > max_sell_history:
|
||||
max_sell_history = cost
|
||||
|
||||
if (
|
||||
self.sale_history_min != min_sell_history
|
||||
or self.sale_history_max != max_sell_history
|
||||
):
|
||||
self.price_modified = True
|
||||
|
||||
self.sale_history_min = min_sell_history
|
||||
self.sale_history_max = max_sell_history
|
||||
|
||||
@@ -4525,7 +4512,10 @@ def update_bom_build_lines(sender, instance, created, **kwargs):
|
||||
def update_pricing_after_edit(sender, instance, created, **kwargs):
|
||||
"""Callback function when a part price break is created or updated."""
|
||||
# Update part pricing *unless* we are importing data
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
if (
|
||||
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
|
||||
and not InvenTree.ready.isImportingData()
|
||||
):
|
||||
if instance.part:
|
||||
instance.part.schedule_pricing_update(create=True)
|
||||
|
||||
@@ -4542,7 +4532,10 @@ def update_pricing_after_edit(sender, instance, created, **kwargs):
|
||||
def update_pricing_after_delete(sender, instance, **kwargs):
|
||||
"""Callback function when a part price break is deleted."""
|
||||
# Update part pricing *unless* we are importing data
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
if (
|
||||
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
|
||||
and not InvenTree.ready.isImportingData()
|
||||
):
|
||||
if instance.part:
|
||||
instance.part.schedule_pricing_update(create=False)
|
||||
|
||||
|
||||
@@ -354,11 +354,9 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
help_text=_('Internal Part Number'),
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
revision = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text=_('Part revision or version number'),
|
||||
max_length=100,
|
||||
required=False, default='', allow_blank=True, allow_null=True, max_length=100
|
||||
)
|
||||
|
||||
# Pricing fields
|
||||
@@ -909,7 +907,7 @@ class PartSerializer(
|
||||
)
|
||||
|
||||
revision = serializers.CharField(
|
||||
required=False, default='', allow_blank=True, max_length=100
|
||||
required=False, default='', allow_blank=True, allow_null=True, max_length=100
|
||||
)
|
||||
|
||||
# Annotated fields
|
||||
|
||||
@@ -73,7 +73,11 @@ def update_part_pricing(pricing: part.models.PartPricing, counter: int = 0):
|
||||
"""
|
||||
logger.info('Updating part pricing for %s', pricing.part)
|
||||
|
||||
pricing.update_pricing(counter=counter)
|
||||
pricing.update_pricing(
|
||||
counter=counter,
|
||||
previous_min=pricing.overall_min,
|
||||
previous_max=pricing.overall_max,
|
||||
)
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for Part pricing calculations."""
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
@@ -88,6 +89,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
part=self.sp_2, quantity=10, price=4.55, price_currency='GBP'
|
||||
)
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_pricing_data(self):
|
||||
"""Test link between Part and PartPricing model."""
|
||||
# Initially there is no associated Pricing data
|
||||
@@ -112,6 +114,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
def test_invalid_rate(self):
|
||||
"""Ensure that conversion behaves properly with missing rates."""
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_simple(self):
|
||||
"""Tests for hard-coded values."""
|
||||
pricing = self.part.pricing
|
||||
@@ -143,6 +146,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
self.assertEqual(pricing.overall_min, Money('0.111111', 'USD'))
|
||||
self.assertEqual(pricing.overall_max, Money('25', 'USD'))
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_supplier_part_pricing(self):
|
||||
"""Test for supplier part pricing."""
|
||||
pricing = self.part.pricing
|
||||
@@ -156,19 +160,22 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
# Creating price breaks will cause the pricing to be updated
|
||||
self.create_price_breaks()
|
||||
|
||||
pricing.update_pricing()
|
||||
pricing = self.part.pricing
|
||||
pricing.refresh_from_db()
|
||||
|
||||
self.assertAlmostEqual(float(pricing.overall_min.amount), 2.015, places=2)
|
||||
self.assertAlmostEqual(float(pricing.overall_max.amount), 3.06, places=2)
|
||||
|
||||
# Delete all supplier parts and re-calculate
|
||||
self.part.supplier_parts.all().delete()
|
||||
pricing.update_pricing()
|
||||
|
||||
pricing = self.part.pricing
|
||||
pricing.refresh_from_db()
|
||||
|
||||
self.assertIsNone(pricing.supplier_price_min)
|
||||
self.assertIsNone(pricing.supplier_price_max)
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_internal_pricing(self):
|
||||
"""Tests for internal price breaks."""
|
||||
# Ensure internal pricing is enabled
|
||||
@@ -188,7 +195,8 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
part=self.part, quantity=ii + 1, price=10 - ii, price_currency=currency
|
||||
)
|
||||
|
||||
pricing.update_internal_cost()
|
||||
pricing = self.part.pricing
|
||||
pricing.refresh_from_db()
|
||||
|
||||
# Expected money value
|
||||
m_expected = Money(10 - ii, currency)
|
||||
@@ -201,6 +209,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
self.assertEqual(pricing.internal_cost_max, Money(10, currency))
|
||||
self.assertEqual(pricing.overall_max, Money(10, currency))
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_stock_item_pricing(self):
|
||||
"""Test for stock item pricing data."""
|
||||
# Create a part
|
||||
@@ -243,6 +252,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
self.assertEqual(pricing.overall_min, Money(1.176471, 'USD'))
|
||||
self.assertEqual(pricing.overall_max, Money(6.666667, 'USD'))
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_bom_pricing(self):
|
||||
"""Unit test for BOM pricing calculations."""
|
||||
pricing = self.part.pricing
|
||||
@@ -252,7 +262,8 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
|
||||
currency = 'AUD'
|
||||
|
||||
for ii in range(10):
|
||||
# Create pricing out of order, to ensure min/max values are calculated correctly
|
||||
for ii in range(5):
|
||||
# Create a new part for the BOM
|
||||
sub_part = part.models.Part.objects.create(
|
||||
name=f'Sub Part {ii}',
|
||||
@@ -273,15 +284,21 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
part=self.part, sub_part=sub_part, quantity=5
|
||||
)
|
||||
|
||||
pricing.update_bom_cost()
|
||||
|
||||
# Check that the values have been updated correctly
|
||||
self.assertEqual(pricing.currency, 'USD')
|
||||
|
||||
# Final overall pricing checks
|
||||
self.assertEqual(pricing.overall_min, Money('366.666665', 'USD'))
|
||||
self.assertEqual(pricing.overall_max, Money('550', 'USD'))
|
||||
# Price range should have been automatically updated
|
||||
self.part.refresh_from_db()
|
||||
pricing = self.part.pricing
|
||||
|
||||
expected_min = 100
|
||||
expected_max = 150
|
||||
|
||||
# Final overall pricing checks
|
||||
self.assertEqual(pricing.overall_min, Money(expected_min, 'USD'))
|
||||
self.assertEqual(pricing.overall_max, Money(expected_max, 'USD'))
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_purchase_pricing(self):
|
||||
"""Unit tests for historical purchase pricing."""
|
||||
self.create_price_breaks()
|
||||
@@ -349,6 +366,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
# Max cost in USD
|
||||
self.assertAlmostEqual(float(pricing.purchase_cost_max.amount), 6.95, places=2)
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_delete_with_pricing(self):
|
||||
"""Test for deleting a part which has pricing information."""
|
||||
# Create some pricing data
|
||||
@@ -373,6 +391,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
with self.assertRaises(part.models.PartPricing.DoesNotExist):
|
||||
pricing.refresh_from_db()
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_delete_without_pricing(self):
|
||||
"""Test that we can delete a part which does not have pricing information."""
|
||||
pricing = self.part.pricing
|
||||
@@ -388,6 +407,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
with self.assertRaises(part.models.Part.DoesNotExist):
|
||||
self.part.refresh_from_db()
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_check_missing_pricing(self):
|
||||
"""Tests for check_missing_pricing background task.
|
||||
|
||||
@@ -411,6 +431,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
# Check that PartPricing objects have been created
|
||||
self.assertEqual(part.models.PartPricing.objects.count(), 101)
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_delete_part_with_stock_items(self):
|
||||
"""Test deleting a part instance with stock items.
|
||||
|
||||
@@ -431,7 +452,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
)
|
||||
|
||||
# Manually schedule a pricing update (does not happen automatically in testing)
|
||||
p.schedule_pricing_update(create=True, test=True)
|
||||
p.schedule_pricing_update(create=True)
|
||||
|
||||
# Check that a PartPricing object exists
|
||||
self.assertTrue(part.models.PartPricing.objects.filter(part=p).exists())
|
||||
@@ -443,5 +464,84 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
|
||||
|
||||
# Try to update pricing (should fail gracefully as the Part has been deleted)
|
||||
p.schedule_pricing_update(create=False, test=True)
|
||||
p.schedule_pricing_update(create=False)
|
||||
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_multi_level_bom(self):
|
||||
"""Test that pricing for multi-level BOMs is calculated correctly."""
|
||||
# Create some parts
|
||||
A1 = part.models.Part.objects.create(
|
||||
name='A1', description='A1', assembly=True, component=True
|
||||
)
|
||||
B1 = part.models.Part.objects.create(
|
||||
name='B1', description='B1', assembly=True, component=True
|
||||
)
|
||||
C1 = part.models.Part.objects.create(
|
||||
name='C1', description='C1', assembly=True, component=True
|
||||
)
|
||||
D1 = part.models.Part.objects.create(
|
||||
name='D1', description='D1', assembly=True, component=True
|
||||
)
|
||||
D2 = part.models.Part.objects.create(
|
||||
name='D2', description='D2', assembly=True, component=True
|
||||
)
|
||||
D3 = part.models.Part.objects.create(
|
||||
name='D3', description='D3', assembly=True, component=True
|
||||
)
|
||||
|
||||
# BOM Items
|
||||
part.models.BomItem.objects.create(part=A1, sub_part=B1, quantity=10)
|
||||
part.models.BomItem.objects.create(part=B1, sub_part=C1, quantity=2)
|
||||
part.models.BomItem.objects.create(part=C1, sub_part=D1, quantity=3)
|
||||
part.models.BomItem.objects.create(part=C1, sub_part=D2, quantity=4)
|
||||
part.models.BomItem.objects.create(part=C1, sub_part=D3, quantity=5)
|
||||
|
||||
# Pricing data (only for low-level D parts)
|
||||
P1 = D1.pricing
|
||||
P1.override_min = 4.50
|
||||
P1.override_max = 5.50
|
||||
P1.save()
|
||||
P1.update_pricing()
|
||||
|
||||
P2 = D2.pricing
|
||||
P2.override_min = 6.50
|
||||
P2.override_max = 7.50
|
||||
P2.save()
|
||||
P2.update_pricing()
|
||||
|
||||
P3 = D3.pricing
|
||||
P3.override_min = 8.50
|
||||
P3.override_max = 9.50
|
||||
P3.save()
|
||||
P3.update_pricing()
|
||||
|
||||
# Simple checks for low-level BOM items
|
||||
self.assertEqual(D1.pricing.overall_min, Money(4.50, 'USD'))
|
||||
self.assertEqual(D1.pricing.overall_max, Money(5.50, 'USD'))
|
||||
|
||||
self.assertEqual(D2.pricing.overall_min, Money(6.50, 'USD'))
|
||||
self.assertEqual(D2.pricing.overall_max, Money(7.50, 'USD'))
|
||||
|
||||
self.assertEqual(D3.pricing.overall_min, Money(8.50, 'USD'))
|
||||
self.assertEqual(D3.pricing.overall_max, Money(9.50, 'USD'))
|
||||
|
||||
# Calculate pricing for "C" level part
|
||||
c_min = 3 * 4.50 + 4 * 6.50 + 5 * 8.50
|
||||
c_max = 3 * 5.50 + 4 * 7.50 + 5 * 9.50
|
||||
|
||||
self.assertEqual(C1.pricing.overall_min, Money(c_min, 'USD'))
|
||||
self.assertEqual(C1.pricing.overall_max, Money(c_max, 'USD'))
|
||||
|
||||
# Calculate pricing for "A" and "B" level parts
|
||||
b_min = 2 * c_min
|
||||
b_max = 2 * c_max
|
||||
|
||||
a_min = 10 * b_min
|
||||
a_max = 10 * b_max
|
||||
|
||||
self.assertEqual(B1.pricing.overall_min, Money(b_min, 'USD'))
|
||||
self.assertEqual(B1.pricing.overall_max, Money(b_max, 'USD'))
|
||||
|
||||
self.assertEqual(A1.pricing.overall_min, Money(a_min, 'USD'))
|
||||
self.assertEqual(A1.pricing.overall_max, Money(a_max, 'USD'))
|
||||
|
||||
@@ -768,7 +768,7 @@ class PluginsRegistry:
|
||||
|
||||
for k in self.plugin_settings_keys():
|
||||
try:
|
||||
val = get_global_setting(k)
|
||||
val = get_global_setting(k, create=False)
|
||||
msg = f'{k}-{val}'
|
||||
|
||||
data.update(msg.encode())
|
||||
|
||||
@@ -350,6 +350,15 @@ class ReportPrint(GenericAPIView):
|
||||
|
||||
output = template.render(instance, request)
|
||||
|
||||
if template.attach_to_model:
|
||||
# Attach the generated report to the model instance
|
||||
data = output.get_document().write_pdf()
|
||||
instance.create_attachment(
|
||||
attachment=ContentFile(data, report_name),
|
||||
comment=_('Report saved at time of printing'),
|
||||
upload_user=request.user,
|
||||
)
|
||||
|
||||
# Provide generated report to any interested plugins
|
||||
for plugin in registry.with_mixin('report'):
|
||||
try:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import os
|
||||
|
||||
from django.db import connection, migrations
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import connection, migrations
|
||||
|
||||
import InvenTree.ready
|
||||
|
||||
@@ -48,7 +48,7 @@ def convert_legacy_labels(table_name, model_name, template_model):
|
||||
except Exception:
|
||||
# Table likely does not exist
|
||||
if not InvenTree.ready.isInTestMode():
|
||||
print(f"Legacy label table {table_name} not found - skipping migration")
|
||||
print(f"\nLegacy label table {table_name} not found - skipping migration")
|
||||
return 0
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.15 on 2024-09-05 23:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('report', '0027_alter_labeltemplate_model_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='labeltemplate',
|
||||
name='attach_to_model',
|
||||
field=models.BooleanField(default=False, help_text='Save report output as an attachment against linked model instance when printing', verbose_name='Attach to Model on Print'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reporttemplate',
|
||||
name='attach_to_model',
|
||||
field=models.BooleanField(default=False, help_text='Save report output as an attachment against linked model instance when printing', verbose_name='Attach to Model on Print'),
|
||||
),
|
||||
]
|
||||
@@ -163,6 +163,14 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
editable=False,
|
||||
)
|
||||
|
||||
attach_to_model = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Attach to Model on Print'),
|
||||
help_text=_(
|
||||
'Save report output as an attachment against linked model instance when printing'
|
||||
),
|
||||
)
|
||||
|
||||
def generate_filename(self, context, **kwargs):
|
||||
"""Generate a filename for this report."""
|
||||
template_string = Template(self.filename_pattern)
|
||||
|
||||
@@ -42,6 +42,7 @@ class ReportSerializerBase(InvenTreeModelSerializer):
|
||||
'filename_pattern',
|
||||
'enabled',
|
||||
'revision',
|
||||
'attach_to_model',
|
||||
]
|
||||
|
||||
template = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
@@ -110,7 +110,7 @@ def uploaded_image(
|
||||
validate=True,
|
||||
**kwargs,
|
||||
):
|
||||
"""Return a fully-qualified path for an 'uploaded' image.
|
||||
"""Return raw image data from an 'uploaded' image.
|
||||
|
||||
Arguments:
|
||||
filename: The filename of the image relative to the MEDIA_ROOT directory
|
||||
@@ -124,7 +124,7 @@ def uploaded_image(
|
||||
rotate: Optional rotation to apply to the image
|
||||
|
||||
Returns:
|
||||
A fully qualified path to the image
|
||||
Binary image data to be rendered directly in a <img> tag
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if the file does not exist
|
||||
|
||||
@@ -555,8 +555,16 @@ class TestReportTest(PrintTestMixins, ReportTest):
|
||||
template = ReportTemplate.objects.filter(
|
||||
enabled=True, model_type='stockitem'
|
||||
).first()
|
||||
|
||||
self.assertIsNotNone(template)
|
||||
|
||||
# Ensure that the 'attach_to_model' attribute is initially False
|
||||
template.attach_to_model = False
|
||||
template.save()
|
||||
template.refresh_from_db()
|
||||
|
||||
self.assertFalse(template.attach_to_model)
|
||||
|
||||
url = reverse(self.print_url)
|
||||
|
||||
# Try to print without providing a valid StockItem
|
||||
@@ -568,18 +576,37 @@ class TestReportTest(PrintTestMixins, ReportTest):
|
||||
# Now print with a valid StockItem
|
||||
item = StockItem.objects.first()
|
||||
|
||||
n = item.attachments.count()
|
||||
|
||||
response = self.post(
|
||||
url, {'template': template.pk, 'items': [item.pk]}, expected_code=201
|
||||
)
|
||||
|
||||
# There should be a link to the generated PDF
|
||||
self.assertEqual(response.data['output'].startswith('/media/report/'), True)
|
||||
self.assertTrue(response.data['output'].startswith('/media/report/'))
|
||||
self.assertTrue(response.data['output'].endswith('.pdf'))
|
||||
|
||||
# By default, this should *not* have created an attachment against this stockitem
|
||||
self.assertEqual(n, item.attachments.count())
|
||||
self.assertFalse(
|
||||
Attachment.objects.filter(model_id=item.pk, model_type='stockitem').exists()
|
||||
)
|
||||
|
||||
# Now try again, but attach the generated PDF to the StockItem
|
||||
template.attach_to_model = True
|
||||
template.save()
|
||||
|
||||
response = self.post(
|
||||
url, {'template': template.pk, 'items': [item.pk]}, expected_code=201
|
||||
)
|
||||
|
||||
# A new attachment should have been created
|
||||
self.assertEqual(n + 1, item.attachments.count())
|
||||
attachment = item.attachments.order_by('-pk').first()
|
||||
|
||||
# The attachment should be a PDF
|
||||
self.assertTrue(attachment.attachment.name.endswith('.pdf'))
|
||||
|
||||
def test_mdl_build(self):
|
||||
"""Test the Build model."""
|
||||
self.run_print_test(Build, 'build', label=False)
|
||||
|
||||
@@ -2278,14 +2278,16 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
|
||||
"""Function to be executed after a StockItem object is deleted."""
|
||||
from part import tasks as part_tasks
|
||||
|
||||
if not InvenTree.ready.isImportingData() and InvenTree.ready.canAppAccessDatabase(
|
||||
allow_test=True
|
||||
):
|
||||
if InvenTree.ready.isImportingData():
|
||||
return
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||
# Run this check in the background
|
||||
InvenTree.tasks.offload_task(
|
||||
part_tasks.notify_low_stock_if_required, instance.part
|
||||
)
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING):
|
||||
# Schedule an update on parent part pricing
|
||||
if instance.part:
|
||||
instance.part.schedule_pricing_update(create=False)
|
||||
@@ -2296,19 +2298,15 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
||||
"""Hook function to be executed after StockItem object is saved/updated."""
|
||||
from part import tasks as part_tasks
|
||||
|
||||
if (
|
||||
created
|
||||
and not InvenTree.ready.isImportingData()
|
||||
and InvenTree.ready.canAppAccessDatabase(allow_test=True)
|
||||
):
|
||||
# Run this check in the background
|
||||
InvenTree.tasks.offload_task(
|
||||
part_tasks.notify_low_stock_if_required, instance.part
|
||||
)
|
||||
if created and not InvenTree.ready.isImportingData():
|
||||
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||
InvenTree.tasks.offload_task(
|
||||
part_tasks.notify_low_stock_if_required, instance.part
|
||||
)
|
||||
|
||||
# Schedule an update on parent part pricing
|
||||
if instance.part:
|
||||
instance.part.schedule_pricing_update(create=True)
|
||||
if InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING):
|
||||
if instance.part:
|
||||
instance.part.schedule_pricing_update(create=True)
|
||||
|
||||
|
||||
class StockItemTracking(InvenTree.models.InvenTreeModel):
|
||||
@@ -2429,28 +2427,25 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
||||
super().clean()
|
||||
|
||||
# If this test result corresponds to a template, check the requirements of the template
|
||||
template = self.template
|
||||
try:
|
||||
template = self.template
|
||||
except PartModels.PartTestTemplate.DoesNotExist:
|
||||
template = None
|
||||
|
||||
if template is None:
|
||||
# Fallback if there is no matching template
|
||||
for template in self.stock_item.part.getTestTemplates():
|
||||
if self.key == template.key:
|
||||
break
|
||||
if not template:
|
||||
raise ValidationError({'template': _('Test template does not exist')})
|
||||
|
||||
if template:
|
||||
if template.requires_value and not self.value:
|
||||
raise ValidationError({
|
||||
'value': _('Value must be provided for this test')
|
||||
})
|
||||
if template.requires_value and not self.value:
|
||||
raise ValidationError({'value': _('Value must be provided for this test')})
|
||||
|
||||
if template.requires_attachment and not self.attachment:
|
||||
raise ValidationError({
|
||||
'attachment': _('Attachment must be uploaded for this test')
|
||||
})
|
||||
if template.requires_attachment and not self.attachment:
|
||||
raise ValidationError({
|
||||
'attachment': _('Attachment must be uploaded for this test')
|
||||
})
|
||||
|
||||
if choices := template.get_choices():
|
||||
if self.value not in choices:
|
||||
raise ValidationError({'value': _('Invalid value for this test')})
|
||||
if choices := template.get_choices():
|
||||
if self.value not in choices:
|
||||
raise ValidationError({'value': _('Invalid value for this test')})
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
|
||||
@@ -268,13 +268,13 @@ class StockItemTestResultSerializer(
|
||||
).first():
|
||||
data['template'] = template
|
||||
|
||||
else:
|
||||
elif get_global_setting('TEST_UPLOAD_CREATE_TEMPLATE', False):
|
||||
logger.debug(
|
||||
"No matching test template found for '%s' - creating a new template",
|
||||
test_name,
|
||||
)
|
||||
|
||||
# Create a new test template based on the provided dasta
|
||||
# Create a new test template based on the provided data
|
||||
data['template'] = part_models.PartTestTemplate.objects.create(
|
||||
part=stock_item.part, test_name=test_name
|
||||
)
|
||||
|
||||
@@ -54,19 +54,15 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Document / label menu -->
|
||||
{% if test_report_enabled or labels_enabled %}
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='document-options' title='{% trans "Printing actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-print'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
{% if test_report_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
|
||||
{% endif %}
|
||||
<li><a class='dropdown-item' href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Print Report" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stock adjustment menu -->
|
||||
{% if user_owns_item %}
|
||||
|
||||
@@ -1716,6 +1716,14 @@ class StockTestResultTest(StockAPITestCase):
|
||||
'notes': 'I guess there was just too much pressure?',
|
||||
}
|
||||
|
||||
# First, test with TEST_UPLOAD_CREATE_TEMPLATE set to False
|
||||
InvenTreeSetting.set_setting('TEST_UPLOAD_CREATE_TEMPLATE', False, self.user)
|
||||
|
||||
response = self.post(url, data, expected_code=400)
|
||||
|
||||
# Again, with the setting enabled
|
||||
InvenTreeSetting.set_setting('TEST_UPLOAD_CREATE_TEMPLATE', True, self.user)
|
||||
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
# Check that a new test template has been created
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="REPORT_LOG_ERRORS" icon="fa-exclamation-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="REPORT_ATTACH_TEST_REPORT" icon="fa-file-upload" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ENFORCE_BOM_INSTALLATION" icon="fa-check-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_OUT_OF_STOCK_TRANSFER" icon="fa-dolly" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="TEST_STATION_DATA" icon="fa-network-wired" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="TEST_UPLOAD_CREATE_TEMPLATE" %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
{% plugins_enabled as plugins_enabled %}
|
||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
||||
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
|
||||
{% settings_value 'RETURNORDER_ENABLED' as return_order_enabled %}
|
||||
{% settings_value "REPORT_ENABLE" as report_enabled %}
|
||||
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
|
||||
|
||||
@@ -24,6 +24,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.permissions
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import (
|
||||
@@ -33,7 +34,11 @@ from InvenTree.mixins import (
|
||||
RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI,
|
||||
)
|
||||
from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer
|
||||
from InvenTree.serializers import (
|
||||
ExtendedUserSerializer,
|
||||
MeUserSerializer,
|
||||
UserCreateSerializer,
|
||||
)
|
||||
from InvenTree.settings import FRONTEND_URL_BASE
|
||||
from users.models import ApiToken, Owner
|
||||
from users.serializers import (
|
||||
@@ -134,24 +139,38 @@ class UserDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for a single user."""
|
||||
|
||||
queryset = User.objects.all()
|
||||
serializer_class = ExendedUserSerializer
|
||||
serializer_class = ExtendedUserSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class MeUserDetail(RetrieveUpdateAPI, UserDetail):
|
||||
"""Detail endpoint for current user."""
|
||||
|
||||
serializer_class = MeUserSerializer
|
||||
|
||||
rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'}
|
||||
|
||||
def get_object(self):
|
||||
"""Always return the current user object."""
|
||||
return self.request.user
|
||||
|
||||
def get_permission_model(self):
|
||||
"""Return the model for the permission check.
|
||||
|
||||
Note that for this endpoint, the current user can *always* edit their own details.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class UserList(ListCreateAPI):
|
||||
"""List endpoint for detail on all users."""
|
||||
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserCreateSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.IsSuperuserOrReadOnly,
|
||||
]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = ['first_name', 'last_name', 'username']
|
||||
|
||||
@@ -16,7 +16,7 @@ def clear_sessions(apps, schema_editor): # pragma: no cover
|
||||
try:
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
engine.SessionStore.clear_expired()
|
||||
print('Cleared all user sessions to deal with GHSA-2crp-q9pc-457j')
|
||||
print('\nCleared all user sessions to deal with GHSA-2crp-q9pc-457j')
|
||||
except Exception:
|
||||
# Database may not be ready yet, so this does not matter anyhow
|
||||
pass
|
||||
|
||||
@@ -17,7 +17,10 @@ class UserAPITests(InvenTreeAPITestCase):
|
||||
self.assignRole('admin.add')
|
||||
response = self.options(reverse('api-user-list'), expected_code=200)
|
||||
|
||||
fields = response.data['actions']['POST']
|
||||
# User is *not* a superuser, so user account API is read-only
|
||||
self.assertNotIn('POST', response.data['actions'])
|
||||
|
||||
fields = response.data['actions']['GET']
|
||||
|
||||
# Check some of the field values
|
||||
self.assertEqual(fields['username']['label'], 'Username')
|
||||
|
||||
Generated
+20
-9
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"eslint": "^9.9.1",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-config-google": "^0.14.0"
|
||||
}
|
||||
},
|
||||
@@ -86,9 +86,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz",
|
||||
"integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==",
|
||||
"version": "9.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.10.0.tgz",
|
||||
"integrity": "sha512-fuXtbiP5GWIn8Fz+LWoOMVf/Jxm+aajZYkhi6CuEm4SxymFM+eUWzbO9qXT+L0iCkL5+KGYMCSGxo686H19S1g==",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
@@ -101,6 +101,17 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.1.0.tgz",
|
||||
"integrity": "sha512-autAXT203ixhqei9xt+qkYOvY8l6LAFIdT2UXc/RPNeUVfqRF1BV94GTJyVPFKT8nFM6MyVJhjLj9E8JWvf5zQ==",
|
||||
"dependencies": {
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
@@ -322,15 +333,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.9.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz",
|
||||
"integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==",
|
||||
"version": "9.10.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.10.0.tgz",
|
||||
"integrity": "sha512-Y4D0IgtBZfOcOUAIQTSXBKoNGfY0REGqHJG6+Q81vNippW5YlKjHFj4soMxamKK1NXHUWuBZTLdU3Km+L/pcHw==",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.11.0",
|
||||
"@eslint/config-array": "^0.18.0",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "9.9.1",
|
||||
"@eslint/js": "9.10.0",
|
||||
"@eslint/plugin-kit": "^0.1.0",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.3.0",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
@@ -353,7 +365,6 @@
|
||||
"is-glob": "^4.0.0",
|
||||
"is-path-inside": "^3.0.3",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"levn": "^0.4.1",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"minimatch": "^3.1.2",
|
||||
"natural-compare": "^1.4.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"eslint": "^9.9.1",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-config-google": "^0.14.0"
|
||||
},
|
||||
"type": "module"
|
||||
|
||||
@@ -257,34 +257,34 @@ coverage[toml]==7.6.1 \
|
||||
--hash=sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234 \
|
||||
--hash=sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc
|
||||
# via -r src/backend/requirements-dev.in
|
||||
cryptography==43.0.0 \
|
||||
--hash=sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709 \
|
||||
--hash=sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069 \
|
||||
--hash=sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2 \
|
||||
--hash=sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b \
|
||||
--hash=sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e \
|
||||
--hash=sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70 \
|
||||
--hash=sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778 \
|
||||
--hash=sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22 \
|
||||
--hash=sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895 \
|
||||
--hash=sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf \
|
||||
--hash=sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431 \
|
||||
--hash=sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f \
|
||||
--hash=sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947 \
|
||||
--hash=sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74 \
|
||||
--hash=sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc \
|
||||
--hash=sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66 \
|
||||
--hash=sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66 \
|
||||
--hash=sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf \
|
||||
--hash=sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f \
|
||||
--hash=sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5 \
|
||||
--hash=sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e \
|
||||
--hash=sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f \
|
||||
--hash=sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55 \
|
||||
--hash=sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1 \
|
||||
--hash=sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47 \
|
||||
--hash=sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5 \
|
||||
--hash=sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0
|
||||
cryptography==43.0.1 \
|
||||
--hash=sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494 \
|
||||
--hash=sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806 \
|
||||
--hash=sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d \
|
||||
--hash=sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062 \
|
||||
--hash=sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2 \
|
||||
--hash=sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4 \
|
||||
--hash=sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1 \
|
||||
--hash=sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85 \
|
||||
--hash=sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84 \
|
||||
--hash=sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042 \
|
||||
--hash=sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d \
|
||||
--hash=sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962 \
|
||||
--hash=sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2 \
|
||||
--hash=sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa \
|
||||
--hash=sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d \
|
||||
--hash=sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365 \
|
||||
--hash=sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96 \
|
||||
--hash=sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47 \
|
||||
--hash=sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d \
|
||||
--hash=sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d \
|
||||
--hash=sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c \
|
||||
--hash=sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb \
|
||||
--hash=sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277 \
|
||||
--hash=sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172 \
|
||||
--hash=sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034 \
|
||||
--hash=sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a \
|
||||
--hash=sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# pdfminer-six
|
||||
@@ -430,9 +430,9 @@ pyyaml==6.0.2 \
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# pre-commit
|
||||
setuptools==73.0.1 \
|
||||
--hash=sha256:b208925fcb9f7af924ed2dc04708ea89791e24bde0d3020b27df0e116088b34e \
|
||||
--hash=sha256:d59a3e788ab7e012ab2c4baed1b376da6366883ee20d7a5fc426816e3d7b1193
|
||||
setuptools==74.1.1 \
|
||||
--hash=sha256:2353af060c06388be1cecbf5953dcdb1f38362f87a2356c480b6b4d5fcfc8847 \
|
||||
--hash=sha256:fc91b5f89e392ef5b77fe143b17e32f65d3024744fba66dc3afe07201684d766
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements-dev.in
|
||||
|
||||
@@ -290,34 +290,34 @@ coreschema==0.0.4 \
|
||||
--hash=sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f \
|
||||
--hash=sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607
|
||||
# via coreapi
|
||||
cryptography==43.0.0 \
|
||||
--hash=sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709 \
|
||||
--hash=sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069 \
|
||||
--hash=sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2 \
|
||||
--hash=sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b \
|
||||
--hash=sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e \
|
||||
--hash=sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70 \
|
||||
--hash=sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778 \
|
||||
--hash=sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22 \
|
||||
--hash=sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895 \
|
||||
--hash=sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf \
|
||||
--hash=sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431 \
|
||||
--hash=sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f \
|
||||
--hash=sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947 \
|
||||
--hash=sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74 \
|
||||
--hash=sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc \
|
||||
--hash=sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66 \
|
||||
--hash=sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66 \
|
||||
--hash=sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf \
|
||||
--hash=sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f \
|
||||
--hash=sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5 \
|
||||
--hash=sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e \
|
||||
--hash=sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f \
|
||||
--hash=sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55 \
|
||||
--hash=sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1 \
|
||||
--hash=sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47 \
|
||||
--hash=sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5 \
|
||||
--hash=sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0
|
||||
cryptography==43.0.1 \
|
||||
--hash=sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494 \
|
||||
--hash=sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806 \
|
||||
--hash=sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d \
|
||||
--hash=sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062 \
|
||||
--hash=sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2 \
|
||||
--hash=sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4 \
|
||||
--hash=sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1 \
|
||||
--hash=sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85 \
|
||||
--hash=sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84 \
|
||||
--hash=sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042 \
|
||||
--hash=sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d \
|
||||
--hash=sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962 \
|
||||
--hash=sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2 \
|
||||
--hash=sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa \
|
||||
--hash=sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d \
|
||||
--hash=sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365 \
|
||||
--hash=sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96 \
|
||||
--hash=sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47 \
|
||||
--hash=sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d \
|
||||
--hash=sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d \
|
||||
--hash=sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c \
|
||||
--hash=sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb \
|
||||
--hash=sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277 \
|
||||
--hash=sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172 \
|
||||
--hash=sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034 \
|
||||
--hash=sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a \
|
||||
--hash=sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289
|
||||
# via
|
||||
# -r src/backend/requirements.in
|
||||
# djangorestframework-simplejwt
|
||||
@@ -1132,8 +1132,8 @@ pillow==10.4.0 \
|
||||
# qrcode
|
||||
# weasyprint
|
||||
pint==0.24.3 \
|
||||
--hash=sha256:d98667e46fd03a1b94694fbfa104ec30858684d8ab26952e2a348b48059089bb \
|
||||
--hash=sha256:998b695e84a34d11702da4a8b9457a39bb5c7ab5ec68db90e948e30878e421f1
|
||||
--hash=sha256:998b695e84a34d11702da4a8b9457a39bb5c7ab5ec68db90e948e30878e421f1 \
|
||||
--hash=sha256:d98667e46fd03a1b94694fbfa104ec30858684d8ab26952e2a348b48059089bb
|
||||
# via -r src/backend/requirements.in
|
||||
pip-licenses==5.0.0 \
|
||||
--hash=sha256:0633a1f9aab58e5a6216931b0e1d5cdded8bcc2709ff563674eb0e2ff9e77e8e \
|
||||
@@ -1602,9 +1602,9 @@ sentry-sdk==2.13.0 \
|
||||
# via
|
||||
# -r src/backend/requirements.in
|
||||
# django-q-sentry
|
||||
setuptools==73.0.1 \
|
||||
--hash=sha256:b208925fcb9f7af924ed2dc04708ea89791e24bde0d3020b27df0e116088b34e \
|
||||
--hash=sha256:d59a3e788ab7e012ab2c4baed1b376da6366883ee20d7a5fc426816e3d7b1193
|
||||
setuptools==74.1.1 \
|
||||
--hash=sha256:2353af060c06388be1cecbf5953dcdb1f38362f87a2356c480b6b4d5fcfc8847 \
|
||||
--hash=sha256:fc91b5f89e392ef5b77fe143b17e32f65d3024744fba66dc3afe07201684d766
|
||||
# via
|
||||
# -r src/backend/requirements.in
|
||||
# django-money
|
||||
|
||||
+19
-17
@@ -24,8 +24,8 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@lingui/core": "^4.11.3",
|
||||
"@lingui/react": "^4.11.3",
|
||||
"@lingui/core": "^4.11.4",
|
||||
"@lingui/react": "^4.11.4",
|
||||
"@mantine/carousel": "^7.12.2",
|
||||
"@mantine/charts": "^7.12.2",
|
||||
"@mantine/core": "^7.12.2",
|
||||
@@ -37,30 +37,31 @@
|
||||
"@mantine/notifications": "^7.12.2",
|
||||
"@mantine/spotlight": "^7.12.2",
|
||||
"@mantine/vanilla-extract": "^7.12.2",
|
||||
"@mdxeditor/editor": "^3.11.3",
|
||||
"@sentry/react": "^8.27.0",
|
||||
"@tabler/icons-react": "^3.14.0",
|
||||
"@tanstack/react-query": "^5.53.1",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.0",
|
||||
"@uiw/react-codemirror": "^4.23.0",
|
||||
"@sentry/react": "^8.29.0",
|
||||
"@tabler/icons-react": "^3.15.0",
|
||||
"@tanstack/react-query": "^5.55.4",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.1",
|
||||
"@uiw/react-codemirror": "^4.23.1",
|
||||
"@uiw/react-split": "^5.9.3",
|
||||
"@vanilla-extract/css": "^1.15.5",
|
||||
"axios": "^1.7.6",
|
||||
"axios": "^1.7.7",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"embla-carousel-react": "^8.2.0",
|
||||
"easymde": "^2.18.0",
|
||||
"embla-carousel-react": "^8.2.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"mantine-datatable": "^7.11.3",
|
||||
"mantine-datatable": "^7.12.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-grid-layout": "^1.4.4",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-is": "^18.3.1",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.8.0",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-window": "^1.8.10",
|
||||
"recharts": "^2.12.7",
|
||||
"styled-components": "^6.1.13",
|
||||
@@ -70,10 +71,11 @@
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@lingui/cli": "^4.11.3",
|
||||
"@lingui/macro": "^4.11.3",
|
||||
"@playwright/test": "^1.46.1",
|
||||
"@types/node": "^22.5.1",
|
||||
"@codecov/vite-plugin": "^1.0.0",
|
||||
"@lingui/cli": "^4.11.4",
|
||||
"@lingui/macro": "^4.11.4",
|
||||
"@playwright/test": "^1.47.0",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -86,7 +88,7 @@
|
||||
"nyc": "^17.0.0",
|
||||
"rollup-plugin-license": "^3.5.2",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2",
|
||||
"vite": "^5.4.3",
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-plugin-istanbul": "^6.0.2"
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default defineConfig({
|
||||
timeout: 120 * 1000
|
||||
},
|
||||
{
|
||||
command: 'invoke server -a 127.0.0.1:8000',
|
||||
command: 'invoke dev.server -a 127.0.0.1:8000',
|
||||
env: {
|
||||
INVENTREE_DEBUG: 'True'
|
||||
},
|
||||
|
||||
@@ -41,8 +41,8 @@ export function ActionButton(props: ActionButtonProps) {
|
||||
aria-label={`action-button-${identifierString(
|
||||
props.tooltip ?? props.text ?? ''
|
||||
)}`}
|
||||
onClick={() => {
|
||||
props.onClick();
|
||||
onClick={(event: any) => {
|
||||
props.onClick(event);
|
||||
}}
|
||||
variant={props.variant ?? 'transparent'}
|
||||
>
|
||||
|
||||
@@ -80,6 +80,7 @@ export default function AdminButton(props: AdminButtonProps) {
|
||||
tooltip={t`Open in admin interface`}
|
||||
hidden={!enabled}
|
||||
onClick={openAdmin}
|
||||
tooltipAlignment="bottom"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Badge } from '@mantine/core';
|
||||
import { Badge, Skeleton } from '@mantine/core';
|
||||
|
||||
import { isTrue } from '../../functions/conversion';
|
||||
|
||||
@@ -32,3 +32,11 @@ export function PassFailButton({
|
||||
export function YesNoButton({ value }: { value: any }) {
|
||||
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
|
||||
}
|
||||
|
||||
export function YesNoUndefinedButton({ value }: { value?: boolean }) {
|
||||
if (value === undefined) {
|
||||
return <Skeleton height={15} width={32} />;
|
||||
} else {
|
||||
return <YesNoButton value={value} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,17 @@
|
||||
// import SimpleMDE from "react-simplemde-editor";
|
||||
import { t } from '@lingui/macro';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
AdmonitionDirectiveDescriptor,
|
||||
BlockTypeSelect,
|
||||
BoldItalicUnderlineToggles,
|
||||
ButtonWithTooltip,
|
||||
CodeToggle,
|
||||
CreateLink,
|
||||
InsertAdmonition,
|
||||
InsertImage,
|
||||
InsertTable,
|
||||
ListsToggle,
|
||||
MDXEditor,
|
||||
type MDXEditorMethods,
|
||||
Separator,
|
||||
UndoRedo,
|
||||
directivesPlugin,
|
||||
headingsPlugin,
|
||||
imagePlugin,
|
||||
linkDialogPlugin,
|
||||
linkPlugin,
|
||||
listsPlugin,
|
||||
markdownShortcutPlugin,
|
||||
quotePlugin,
|
||||
tablePlugin,
|
||||
toolbarPlugin
|
||||
} from '@mdxeditor/editor';
|
||||
import '@mdxeditor/editor/style.css';
|
||||
import { IconDeviceFloppy, IconEdit, IconEye } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React from 'react';
|
||||
import EasyMDE, { default as SimpleMde } from 'easymde';
|
||||
import 'easymde/dist/easymde.min.css';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import SimpleMDE from 'react-simplemde-editor';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { ModelInformationDict } from '../render/ModelType';
|
||||
|
||||
/*
|
||||
@@ -74,7 +49,7 @@ async function uploadNotesImage(
|
||||
|
||||
/*
|
||||
* A text editor component for editing notes against a model type and instance.
|
||||
* Uses the MDXEditor component - https://mdxeditor.dev/
|
||||
* Uses the react-simple-mde editor: https://github.com/RIP21/react-simplemde-editor
|
||||
*
|
||||
* TODO:
|
||||
* - Disable editing by default when the component is launched - user can click an "edit" button to enable
|
||||
@@ -90,13 +65,13 @@ export default function NotesEditor({
|
||||
modelId: number;
|
||||
editable?: boolean;
|
||||
}) {
|
||||
const ref = React.useRef<MDXEditorMethods>(null);
|
||||
|
||||
const { host } = useLocalState();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
// In addition to the editable prop, we also need to check if the user has "enabled" editing
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
|
||||
const [markdown, setMarkdown] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Initially disable editing mode on load
|
||||
setEditing(false);
|
||||
@@ -107,27 +82,51 @@ export default function NotesEditor({
|
||||
return apiUrl(modelInfo.api_endpoint, modelId);
|
||||
}, [modelType, modelId]);
|
||||
|
||||
// Image upload handler
|
||||
const imageUploadHandler = useCallback(
|
||||
(image: File): Promise<string> => {
|
||||
return uploadNotesImage(image, modelType, modelId);
|
||||
(
|
||||
file: File,
|
||||
onSuccess: (url: string) => void,
|
||||
onError: (error: string) => void
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
formData.append('model_type', modelType);
|
||||
formData.append('model_id', modelId.toString());
|
||||
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.notes_image_upload), formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error.message);
|
||||
notifications.hide('notes');
|
||||
notifications.show({
|
||||
id: 'notes',
|
||||
title: t`Error`,
|
||||
message: t`Image upload failed`,
|
||||
color: 'red'
|
||||
});
|
||||
})
|
||||
.then((response: any) => {
|
||||
onSuccess(response.data.image);
|
||||
notifications.hide('notes');
|
||||
notifications.show({
|
||||
id: 'notes',
|
||||
title: t`Success`,
|
||||
message: t`Image uploaded successfully`,
|
||||
color: 'green'
|
||||
});
|
||||
});
|
||||
},
|
||||
[modelType, modelId]
|
||||
);
|
||||
|
||||
const imagePreviewHandler = useCallback(
|
||||
async (image: string): Promise<string> => {
|
||||
// If the image is a relative URL, then we need to prepend the base URL
|
||||
if (image.startsWith('/media/')) {
|
||||
image = host + image;
|
||||
}
|
||||
|
||||
return image;
|
||||
},
|
||||
[host]
|
||||
);
|
||||
|
||||
const dataQuery = useQuery({
|
||||
queryKey: [noteUrl],
|
||||
queryKey: ['notes-editor', noteUrl, modelType, modelId],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(noteUrl)
|
||||
@@ -137,124 +136,119 @@ export default function NotesEditor({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.setMarkdown(dataQuery.data ?? '');
|
||||
}, [dataQuery.data, ref.current]);
|
||||
setMarkdown(dataQuery.data ?? '');
|
||||
}, [dataQuery.data]);
|
||||
|
||||
// Callback to save notes to the server
|
||||
const saveNotes = useCallback(() => {
|
||||
const markdown = ref.current?.getMarkdown();
|
||||
const saveNotes = useCallback(
|
||||
(markdown: string) => {
|
||||
if (!noteUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!noteUrl || markdown === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.patch(noteUrl, { notes: markdown })
|
||||
.then(() => {
|
||||
notifications.hide('notes');
|
||||
notifications.show({
|
||||
title: t`Success`,
|
||||
message: t`Notes saved successfully`,
|
||||
color: 'green',
|
||||
id: 'notes'
|
||||
api
|
||||
.patch(noteUrl, { notes: markdown })
|
||||
.then(() => {
|
||||
notifications.hide('notes');
|
||||
notifications.show({
|
||||
title: t`Success`,
|
||||
message: t`Notes saved successfully`,
|
||||
color: 'green',
|
||||
id: 'notes'
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
notifications.hide('notes');
|
||||
notifications.show({
|
||||
title: t`Error`,
|
||||
message: t`Failed to save notes`,
|
||||
color: 'red',
|
||||
id: 'notes'
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
notifications.hide('notes');
|
||||
notifications.show({
|
||||
title: t`Error`,
|
||||
message: t`Failed to save notes`,
|
||||
color: 'red',
|
||||
id: 'notes'
|
||||
});
|
||||
});
|
||||
}, [api, noteUrl, ref.current]);
|
||||
},
|
||||
[api, noteUrl]
|
||||
);
|
||||
|
||||
const plugins: any[] = useMemo(() => {
|
||||
let plg = [
|
||||
directivesPlugin({
|
||||
directiveDescriptors: [AdmonitionDirectiveDescriptor]
|
||||
}),
|
||||
headingsPlugin(),
|
||||
imagePlugin({
|
||||
imageUploadHandler: imageUploadHandler,
|
||||
imagePreviewHandler: imagePreviewHandler,
|
||||
disableImageResize: true // Note: To enable image resize, we must allow HTML tags in the server
|
||||
}),
|
||||
linkPlugin(),
|
||||
linkDialogPlugin(),
|
||||
listsPlugin(),
|
||||
markdownShortcutPlugin(),
|
||||
quotePlugin(),
|
||||
tablePlugin()
|
||||
];
|
||||
const editorOptions: SimpleMde.Options = useMemo(() => {
|
||||
let icons: any[] = [];
|
||||
|
||||
let toolbar: ReactNode[] = [];
|
||||
if (editable) {
|
||||
toolbar = [
|
||||
<ButtonWithTooltip
|
||||
key="toggle-editing"
|
||||
aria-label="toggle-notes-editing"
|
||||
title={editing ? t`Preview Notes` : t`Edit Notes`}
|
||||
onClick={() => setEditing(!editing)}
|
||||
>
|
||||
{editing ? <IconEye /> : <IconEdit />}
|
||||
</ButtonWithTooltip>
|
||||
];
|
||||
|
||||
if (editing) {
|
||||
toolbar = [
|
||||
...toolbar,
|
||||
<ButtonWithTooltip
|
||||
key="save-notes"
|
||||
aria-label="save-notes"
|
||||
onClick={() => saveNotes()}
|
||||
title={t`Save Notes`}
|
||||
disabled={false}
|
||||
>
|
||||
<IconDeviceFloppy />
|
||||
</ButtonWithTooltip>,
|
||||
<Separator key="separator-1" />,
|
||||
<UndoRedo key="undo-redo" />,
|
||||
<Separator key="separator-2" />,
|
||||
<BoldItalicUnderlineToggles key="bold-italic-underline" />,
|
||||
<CodeToggle key="code-toggle" />,
|
||||
<ListsToggle key="lists-toggle" />,
|
||||
<Separator key="separator-3" />,
|
||||
<BlockTypeSelect key="block-type" />,
|
||||
<Separator key="separator-4" />,
|
||||
<CreateLink key="create-link" />,
|
||||
<InsertTable key="insert-table" />,
|
||||
<InsertAdmonition key="insert-admonition" />
|
||||
];
|
||||
icons.push({
|
||||
name: 'edit-disabled',
|
||||
action: () => setEditing(false),
|
||||
className: 'fa fa-eye',
|
||||
title: t`Disable Editing`
|
||||
});
|
||||
|
||||
icons.push('|', 'side-by-side', '|');
|
||||
} else {
|
||||
icons.push({
|
||||
name: 'edit-enabled',
|
||||
action: () => setEditing(true),
|
||||
className: 'fa fa-edit',
|
||||
title: t`Enable Editing`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is allowed to edit, then add the toolbar
|
||||
if (editable) {
|
||||
plg.push(
|
||||
toolbarPlugin({
|
||||
toolbarContents: () => (
|
||||
<>
|
||||
{toolbar.map((item, index) => item)}
|
||||
{editing && <InsertImage />}
|
||||
</>
|
||||
)
|
||||
})
|
||||
);
|
||||
if (editing) {
|
||||
icons.push('heading-1', 'heading-2', 'heading-3', '|'); // Headings
|
||||
icons.push('bold', 'italic', 'strikethrough', '|'); // Text styles
|
||||
icons.push('unordered-list', 'ordered-list', 'code', 'quote', '|'); // Text formatting
|
||||
icons.push('table', 'link', 'image', '|');
|
||||
icons.push('horizontal-rule', '|', 'guide'); // Misc
|
||||
|
||||
icons.push('|', 'undo', 'redo'); // Undo/Redo
|
||||
icons.push('|');
|
||||
|
||||
icons.push({
|
||||
name: 'save-notes',
|
||||
action: (editor: SimpleMde) => {
|
||||
saveNotes(editor.value());
|
||||
},
|
||||
className: 'fa fa-save',
|
||||
title: t`Save Notes`
|
||||
});
|
||||
}
|
||||
|
||||
return plg;
|
||||
}, [
|
||||
dataQuery.data,
|
||||
editable,
|
||||
editing,
|
||||
imageUploadHandler,
|
||||
imagePreviewHandler,
|
||||
saveNotes
|
||||
]);
|
||||
return {
|
||||
toolbar: icons,
|
||||
uploadImage: true,
|
||||
imagePathAbsolute: true,
|
||||
imageUploadFunction: imageUploadHandler,
|
||||
sideBySideFullscreen: false,
|
||||
shortcuts: {},
|
||||
spellChecker: false
|
||||
};
|
||||
}, [editable, editing]);
|
||||
|
||||
const [mdeInstance, setMdeInstance] = useState<SimpleMde | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (mdeInstance) {
|
||||
let previewMode = !(editable && editing);
|
||||
|
||||
mdeInstance.codemirror?.setOption('readOnly', previewMode);
|
||||
|
||||
// Ensure the preview mode is toggled if required
|
||||
if (mdeInstance.isPreviewActive() != previewMode) {
|
||||
let sibling = mdeInstance?.codemirror.getWrapperElement()?.nextSibling;
|
||||
|
||||
if (sibling != null) {
|
||||
EasyMDE.togglePreview(mdeInstance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [mdeInstance, editable, editing]);
|
||||
|
||||
return (
|
||||
<MDXEditor ref={ref} markdown={''} readOnly={!editable} plugins={plugins} />
|
||||
<SimpleMDE
|
||||
value={markdown}
|
||||
onChange={setMarkdown}
|
||||
options={editorOptions}
|
||||
getMdeInstance={(instance: SimpleMde) => setMdeInstance(instance)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -675,7 +675,7 @@ export function EditApiForm({
|
||||
() => ({
|
||||
...props,
|
||||
fetchInitialData: props.fetchInitialData ?? true,
|
||||
submitText: t`Update` ?? props.submitText,
|
||||
submitText: props.submitText ?? t`Update`,
|
||||
method: 'PUT'
|
||||
}),
|
||||
[props]
|
||||
|
||||
@@ -28,11 +28,12 @@ export default function TextField({
|
||||
|
||||
const { value } = field;
|
||||
|
||||
const [rawText, setRawText] = useState(value);
|
||||
const [rawText, setRawText] = useState<string>(value || '');
|
||||
|
||||
const [debouncedText] = useDebouncedValue(rawText, 250);
|
||||
|
||||
useEffect(() => {
|
||||
setRawText(value);
|
||||
setRawText(value || '');
|
||||
}, [value]);
|
||||
|
||||
const onTextChange = useCallback((value: any) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Indicator,
|
||||
IndicatorProps,
|
||||
Menu,
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconLink,
|
||||
IconQrcode,
|
||||
@@ -42,13 +44,15 @@ export function ActionDropdown({
|
||||
tooltip,
|
||||
actions,
|
||||
disabled = false,
|
||||
hidden = false
|
||||
hidden = false,
|
||||
noindicator = false
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
actions: ActionDropdownItem[];
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
noindicator?: boolean;
|
||||
}) {
|
||||
const hasActions = useMemo(() => {
|
||||
return actions.some((action) => !action.hidden);
|
||||
@@ -66,16 +70,25 @@ export function ActionDropdown({
|
||||
<Menu position="bottom-end" key={menuName}>
|
||||
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={tooltip} hidden={!tooltip}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
<Tooltip label={tooltip} hidden={!tooltip} position="bottom">
|
||||
<Button
|
||||
radius="sm"
|
||||
variant="transparent"
|
||||
variant={noindicator ? 'transparent' : 'light'}
|
||||
disabled={disabled}
|
||||
aria-label={menuName}
|
||||
p="0"
|
||||
size="sm"
|
||||
rightSection={
|
||||
noindicator || disabled ? null : (
|
||||
<IconChevronDown stroke={1.5} />
|
||||
)
|
||||
}
|
||||
styles={{
|
||||
section: { margin: 0 }
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
</Indicator>
|
||||
@@ -110,6 +123,26 @@ export function ActionDropdown({
|
||||
) : null;
|
||||
}
|
||||
|
||||
export function OptionsActionDropdown({
|
||||
actions = [],
|
||||
tooltip = t`Options`,
|
||||
hidden = false
|
||||
}: {
|
||||
actions: ActionDropdownItem[];
|
||||
tooltip?: string;
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ActionDropdown
|
||||
icon={<IconDotsVertical />}
|
||||
tooltip={tooltip}
|
||||
actions={actions}
|
||||
hidden={hidden}
|
||||
noindicator
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown menu for barcode actions
|
||||
export function BarcodeActionDropdown({
|
||||
model,
|
||||
|
||||
@@ -39,7 +39,7 @@ import { useUserSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { Boundary } from '../Boundary';
|
||||
import { RenderInstance } from '../render/Instance';
|
||||
import { ModelInformationDict } from '../render/ModelType';
|
||||
import { ModelInformationDict, getModelInfo } from '../render/ModelType';
|
||||
|
||||
// Define type for handling individual search queries
|
||||
type SearchQuery = {
|
||||
@@ -65,7 +65,7 @@ function QueryResultGroup({
|
||||
return null;
|
||||
}
|
||||
|
||||
const model = ModelInformationDict[query.model];
|
||||
const model = getModelInfo(query.model);
|
||||
|
||||
return (
|
||||
<Paper shadow="sm" radius="xs" p="md" key={`paper-${query.model}`}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Text } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@@ -70,7 +71,9 @@ export function RenderSupplierPart(
|
||||
primary={supplier?.name}
|
||||
secondary={instance.SKU}
|
||||
image={part?.thumbnail ?? part?.image}
|
||||
suffix={part.full_name}
|
||||
suffix={
|
||||
part.full_name ? <Text size="sm">{part.full_name}</Text> : undefined
|
||||
}
|
||||
url={
|
||||
props.link
|
||||
? getDetailUrl(ModelType.supplierpart, instance.pk)
|
||||
@@ -95,7 +98,9 @@ export function RenderManufacturerPart(
|
||||
{...props}
|
||||
primary={manufacturer.name}
|
||||
secondary={instance.MPN}
|
||||
suffix={part.full_name}
|
||||
suffix={
|
||||
part.full_name ? <Text size="sm">{part.full_name}</Text> : undefined
|
||||
}
|
||||
image={manufacturer?.thumnbnail ?? manufacturer.image}
|
||||
url={
|
||||
props.link
|
||||
|
||||
@@ -13,14 +13,20 @@ export interface ModelInformationInterface {
|
||||
admin_url?: string;
|
||||
}
|
||||
|
||||
export interface TranslatableModelInformationInterface
|
||||
extends Omit<ModelInformationInterface, 'label' | 'label_multiple'> {
|
||||
label: () => string;
|
||||
label_multiple: () => string;
|
||||
}
|
||||
|
||||
export type ModelDict = {
|
||||
[key in keyof typeof ModelType]: ModelInformationInterface;
|
||||
[key in keyof typeof ModelType]: TranslatableModelInformationInterface;
|
||||
};
|
||||
|
||||
export const ModelInformationDict: ModelDict = {
|
||||
part: {
|
||||
label: t`Part`,
|
||||
label_multiple: t`Parts`,
|
||||
label: () => t`Part`,
|
||||
label_multiple: () => t`Parts`,
|
||||
url_overview: '/part',
|
||||
url_detail: '/part/:pk/',
|
||||
cui_detail: '/part/:pk/',
|
||||
@@ -28,22 +34,22 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/part/part/'
|
||||
},
|
||||
partparametertemplate: {
|
||||
label: t`Part Parameter Template`,
|
||||
label_multiple: t`Part Parameter Templates`,
|
||||
label: () => t`Part Parameter Template`,
|
||||
label_multiple: () => t`Part Parameter Templates`,
|
||||
url_overview: '/partparametertemplate',
|
||||
url_detail: '/partparametertemplate/:pk/',
|
||||
api_endpoint: ApiEndpoints.part_parameter_template_list
|
||||
},
|
||||
parttesttemplate: {
|
||||
label: t`Part Test Template`,
|
||||
label_multiple: t`Part Test Templates`,
|
||||
label: () => t`Part Test Template`,
|
||||
label_multiple: () => t`Part Test Templates`,
|
||||
url_overview: '/parttesttemplate',
|
||||
url_detail: '/parttesttemplate/:pk/',
|
||||
api_endpoint: ApiEndpoints.part_test_template_list
|
||||
},
|
||||
supplierpart: {
|
||||
label: t`Supplier Part`,
|
||||
label_multiple: t`Supplier Parts`,
|
||||
label: () => t`Supplier Part`,
|
||||
label_multiple: () => t`Supplier Parts`,
|
||||
url_overview: '/supplierpart',
|
||||
url_detail: '/purchasing/supplier-part/:pk/',
|
||||
cui_detail: '/supplier-part/:pk/',
|
||||
@@ -51,8 +57,8 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/company/supplierpart/'
|
||||
},
|
||||
manufacturerpart: {
|
||||
label: t`Manufacturer Part`,
|
||||
label_multiple: t`Manufacturer Parts`,
|
||||
label: () => t`Manufacturer Part`,
|
||||
label_multiple: () => t`Manufacturer Parts`,
|
||||
url_overview: '/manufacturerpart',
|
||||
url_detail: '/purchasing/manufacturer-part/:pk/',
|
||||
cui_detail: '/manufacturer-part/:pk/',
|
||||
@@ -60,8 +66,8 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/company/manufacturerpart/'
|
||||
},
|
||||
partcategory: {
|
||||
label: t`Part Category`,
|
||||
label_multiple: t`Part Categories`,
|
||||
label: () => t`Part Category`,
|
||||
label_multiple: () => t`Part Categories`,
|
||||
url_overview: '/part/category',
|
||||
url_detail: '/part/category/:pk/',
|
||||
cui_detail: '/part/category/:pk/',
|
||||
@@ -69,8 +75,8 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/part/partcategory/'
|
||||
},
|
||||
stockitem: {
|
||||
label: t`Stock Item`,
|
||||
label_multiple: t`Stock Items`,
|
||||
label: () => t`Stock Item`,
|
||||
label_multiple: () => t`Stock Items`,
|
||||
url_overview: '/stock/item',
|
||||
url_detail: '/stock/item/:pk/',
|
||||
cui_detail: '/stock/item/:pk/',
|
||||
@@ -78,8 +84,8 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/stock/stockitem/'
|
||||
},
|
||||
stocklocation: {
|
||||
label: t`Stock Location`,
|
||||
label_multiple: t`Stock Locations`,
|
||||
label: () => t`Stock Location`,
|
||||
label_multiple: () => t`Stock Locations`,
|
||||
url_overview: '/stock/location',
|
||||
url_detail: '/stock/location/:pk/',
|
||||
cui_detail: '/stock/location/:pk/',
|
||||
@@ -87,18 +93,18 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/stock/stocklocation/'
|
||||
},
|
||||
stocklocationtype: {
|
||||
label: t`Stock Location Type`,
|
||||
label_multiple: t`Stock Location Types`,
|
||||
label: () => t`Stock Location Type`,
|
||||
label_multiple: () => t`Stock Location Types`,
|
||||
api_endpoint: ApiEndpoints.stock_location_type_list
|
||||
},
|
||||
stockhistory: {
|
||||
label: t`Stock History`,
|
||||
label_multiple: t`Stock Histories`,
|
||||
label: () => t`Stock History`,
|
||||
label_multiple: () => t`Stock Histories`,
|
||||
api_endpoint: ApiEndpoints.stock_tracking_list
|
||||
},
|
||||
build: {
|
||||
label: t`Build`,
|
||||
label_multiple: t`Builds`,
|
||||
label: () => t`Build`,
|
||||
label_multiple: () => t`Builds`,
|
||||
url_overview: '/build',
|
||||
url_detail: '/build/:pk/',
|
||||
cui_detail: '/build/:pk/',
|
||||
@@ -106,21 +112,21 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/build/build/'
|
||||
},
|
||||
buildline: {
|
||||
label: t`Build Line`,
|
||||
label_multiple: t`Build Lines`,
|
||||
label: () => t`Build Line`,
|
||||
label_multiple: () => t`Build Lines`,
|
||||
url_overview: '/build/line',
|
||||
url_detail: '/build/line/:pk/',
|
||||
cui_detail: '/build/line/:pk/',
|
||||
api_endpoint: ApiEndpoints.build_line_list
|
||||
},
|
||||
builditem: {
|
||||
label: t`Build Item`,
|
||||
label_multiple: t`Build Items`,
|
||||
label: () => t`Build Item`,
|
||||
label_multiple: () => t`Build Items`,
|
||||
api_endpoint: ApiEndpoints.build_item_list
|
||||
},
|
||||
company: {
|
||||
label: t`Company`,
|
||||
label_multiple: t`Companies`,
|
||||
label: () => t`Company`,
|
||||
label_multiple: () => t`Companies`,
|
||||
url_overview: '/company',
|
||||
url_detail: '/company/:pk/',
|
||||
cui_detail: '/company/:pk/',
|
||||
@@ -128,15 +134,15 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/company/company/'
|
||||
},
|
||||
projectcode: {
|
||||
label: t`Project Code`,
|
||||
label_multiple: t`Project Codes`,
|
||||
label: () => t`Project Code`,
|
||||
label_multiple: () => t`Project Codes`,
|
||||
url_overview: '/project-code',
|
||||
url_detail: '/project-code/:pk/',
|
||||
api_endpoint: ApiEndpoints.project_code_list
|
||||
},
|
||||
purchaseorder: {
|
||||
label: t`Purchase Order`,
|
||||
label_multiple: t`Purchase Orders`,
|
||||
label: () => t`Purchase Order`,
|
||||
label_multiple: () => t`Purchase Orders`,
|
||||
url_overview: '/purchasing/purchase-order',
|
||||
url_detail: '/purchasing/purchase-order/:pk/',
|
||||
cui_detail: '/order/purchase-order/:pk/',
|
||||
@@ -144,13 +150,13 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/order/purchaseorder/'
|
||||
},
|
||||
purchaseorderlineitem: {
|
||||
label: t`Purchase Order Line`,
|
||||
label_multiple: t`Purchase Order Lines`,
|
||||
label: () => t`Purchase Order Line`,
|
||||
label_multiple: () => t`Purchase Order Lines`,
|
||||
api_endpoint: ApiEndpoints.purchase_order_line_list
|
||||
},
|
||||
salesorder: {
|
||||
label: t`Sales Order`,
|
||||
label_multiple: t`Sales Orders`,
|
||||
label: () => t`Sales Order`,
|
||||
label_multiple: () => t`Sales Orders`,
|
||||
url_overview: '/sales/sales-order',
|
||||
url_detail: '/sales/sales-order/:pk/',
|
||||
cui_detail: '/order/sales-order/:pk/',
|
||||
@@ -158,15 +164,15 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/order/salesorder/'
|
||||
},
|
||||
salesordershipment: {
|
||||
label: t`Sales Order Shipment`,
|
||||
label_multiple: t`Sales Order Shipments`,
|
||||
label: () => t`Sales Order Shipment`,
|
||||
label_multiple: () => t`Sales Order Shipments`,
|
||||
url_overview: '/salesordershipment',
|
||||
url_detail: '/salesordershipment/:pk/',
|
||||
api_endpoint: ApiEndpoints.sales_order_shipment_list
|
||||
},
|
||||
returnorder: {
|
||||
label: t`Return Order`,
|
||||
label_multiple: t`Return Orders`,
|
||||
label: () => t`Return Order`,
|
||||
label_multiple: () => t`Return Orders`,
|
||||
url_overview: '/sales/return-order',
|
||||
url_detail: '/sales/return-order/:pk/',
|
||||
cui_detail: '/order/return-order/:pk/',
|
||||
@@ -174,86 +180,90 @@ export const ModelInformationDict: ModelDict = {
|
||||
admin_url: '/order/returnorder/'
|
||||
},
|
||||
returnorderlineitem: {
|
||||
label: t`Return Order Line Item`,
|
||||
label_multiple: t`Return Order Line Items`,
|
||||
label: () => t`Return Order Line Item`,
|
||||
label_multiple: () => t`Return Order Line Items`,
|
||||
api_endpoint: ApiEndpoints.return_order_line_list
|
||||
},
|
||||
address: {
|
||||
label: t`Address`,
|
||||
label_multiple: t`Addresses`,
|
||||
label: () => t`Address`,
|
||||
label_multiple: () => t`Addresses`,
|
||||
url_overview: '/address',
|
||||
url_detail: '/address/:pk/',
|
||||
api_endpoint: ApiEndpoints.address_list
|
||||
},
|
||||
contact: {
|
||||
label: t`Contact`,
|
||||
label_multiple: t`Contacts`,
|
||||
label: () => t`Contact`,
|
||||
label_multiple: () => t`Contacts`,
|
||||
url_overview: '/contact',
|
||||
url_detail: '/contact/:pk/',
|
||||
api_endpoint: ApiEndpoints.contact_list
|
||||
},
|
||||
owner: {
|
||||
label: t`Owner`,
|
||||
label_multiple: t`Owners`,
|
||||
label: () => t`Owner`,
|
||||
label_multiple: () => t`Owners`,
|
||||
url_overview: '/owner',
|
||||
url_detail: '/owner/:pk/',
|
||||
api_endpoint: ApiEndpoints.owner_list
|
||||
},
|
||||
user: {
|
||||
label: t`User`,
|
||||
label_multiple: t`Users`,
|
||||
label: () => t`User`,
|
||||
label_multiple: () => t`Users`,
|
||||
url_overview: '/user',
|
||||
url_detail: '/user/:pk/',
|
||||
api_endpoint: ApiEndpoints.user_list
|
||||
},
|
||||
group: {
|
||||
label: t`Group`,
|
||||
label_multiple: t`Groups`,
|
||||
label: () => t`Group`,
|
||||
label_multiple: () => t`Groups`,
|
||||
url_overview: '/user/group',
|
||||
url_detail: '/user/group-:pk',
|
||||
api_endpoint: ApiEndpoints.group_list,
|
||||
admin_url: '/auth/group/'
|
||||
},
|
||||
importsession: {
|
||||
label: t`Import Session`,
|
||||
label_multiple: t`Import Sessions`,
|
||||
label: () => t`Import Session`,
|
||||
label_multiple: () => t`Import Sessions`,
|
||||
url_overview: '/import',
|
||||
url_detail: '/import/:pk/',
|
||||
api_endpoint: ApiEndpoints.import_session_list
|
||||
},
|
||||
labeltemplate: {
|
||||
label: t`Label Template`,
|
||||
label_multiple: t`Label Templates`,
|
||||
label: () => t`Label Template`,
|
||||
label_multiple: () => t`Label Templates`,
|
||||
url_overview: '/labeltemplate',
|
||||
url_detail: '/labeltemplate/:pk/',
|
||||
api_endpoint: ApiEndpoints.label_list
|
||||
},
|
||||
reporttemplate: {
|
||||
label: t`Report Template`,
|
||||
label_multiple: t`Report Templates`,
|
||||
label: () => t`Report Template`,
|
||||
label_multiple: () => t`Report Templates`,
|
||||
url_overview: '/reporttemplate',
|
||||
url_detail: '/reporttemplate/:pk/',
|
||||
api_endpoint: ApiEndpoints.report_list
|
||||
},
|
||||
pluginconfig: {
|
||||
label: t`Plugin Configuration`,
|
||||
label_multiple: t`Plugin Configurations`,
|
||||
label: () => t`Plugin Configuration`,
|
||||
label_multiple: () => t`Plugin Configurations`,
|
||||
url_overview: '/pluginconfig',
|
||||
url_detail: '/pluginconfig/:pk/',
|
||||
api_endpoint: ApiEndpoints.plugin_list
|
||||
},
|
||||
contenttype: {
|
||||
label: t`Content Type`,
|
||||
label_multiple: t`Content Types`,
|
||||
label: () => t`Content Type`,
|
||||
label_multiple: () => t`Content Types`,
|
||||
api_endpoint: ApiEndpoints.content_type_list
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Extract model definition given the provided type
|
||||
* Extract model definition given the provided type - returns translatable strings for labels as string, not functions
|
||||
* @param type - ModelType to extract information from
|
||||
* @returns ModelInformationInterface
|
||||
*/
|
||||
export function getModelInfo(type: ModelType): ModelInformationInterface {
|
||||
return ModelInformationDict[type];
|
||||
return {
|
||||
...ModelInformationDict[type],
|
||||
label: ModelInformationDict[type].label(),
|
||||
label_multiple: ModelInformationDict[type].label_multiple()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Text } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@@ -66,7 +67,7 @@ export function RenderStockItem(
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.part_detail?.full_name}
|
||||
suffix={quantity_string}
|
||||
suffix={<Text size="sm">{quantity_string}</Text>}
|
||||
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
|
||||
url={
|
||||
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined
|
||||
|
||||
@@ -31,6 +31,7 @@ export enum ApiEndpoints {
|
||||
// Generic API endpoints
|
||||
currency_list = 'currency/exchange/',
|
||||
currency_refresh = 'currency/refresh/',
|
||||
all_units = 'units/all/',
|
||||
task_overview = 'background-task/',
|
||||
task_pending_list = 'background-task/pending/',
|
||||
task_scheduled_list = 'background-task/scheduled/',
|
||||
@@ -152,6 +153,7 @@ export enum ApiEndpoints {
|
||||
sales_order_extra_line_list = 'order/so-extra-line/',
|
||||
sales_order_allocation_list = 'order/so-allocation/',
|
||||
sales_order_shipment_list = 'order/so/shipment/',
|
||||
sales_order_allocate_serials = 'order/so/:id/allocate-serials/',
|
||||
|
||||
return_order_list = 'order/ro/',
|
||||
return_order_issue = 'order/ro/:id/issue/',
|
||||
|
||||
@@ -84,6 +84,31 @@ export function useSalesOrderLineItemFields({
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function useSalesOrderAllocateSerialsFields({
|
||||
itemId,
|
||||
orderId
|
||||
}: {
|
||||
itemId: number;
|
||||
orderId: number;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
line_item: {
|
||||
value: itemId,
|
||||
hidden: true
|
||||
},
|
||||
quantity: {},
|
||||
serial_numbers: {},
|
||||
shipment: {
|
||||
filters: {
|
||||
order: orderId,
|
||||
shipped: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [itemId, orderId]);
|
||||
}
|
||||
|
||||
export function useSalesOrderShipmentFields(): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
|
||||
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
Reference in New Issue
Block a user