2
0
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:
Matthias Mair
2024-09-15 21:10:05 +02:00
committed by GitHub
190 changed files with 52867 additions and 52013 deletions
+13 -1
View File
@@ -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
+40 -5
View File
@@ -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."""
+31 -2
View File
@@ -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
+2 -2
View File
@@ -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
+10 -2
View File
@@ -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
+34
View File
@@ -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'),
]),
),
+8 -14
View File
@@ -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."""
+8 -7
View File
@@ -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."""
+8 -2
View File
@@ -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
+19 -11
View File
@@ -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})
+50 -57
View File
@@ -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)
+3 -5
View File
@@ -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
+5 -1
View File
@@ -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)
+111 -11
View File
@@ -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'))
+1 -1
View File
@@ -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())
+9
View File
@@ -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()
@@ -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'),
),
]
+8
View File
@@ -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
+28 -1
View File
@@ -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)
+28 -33
View File
@@ -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):
+2 -2
View File
@@ -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 %}
+8
View File
@@ -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 %}
+22 -3
View File
@@ -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
+4 -1
View File
@@ -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')
+20 -9
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"dependencies": {
"eslint": "^9.9.1",
"eslint": "^9.10.0",
"eslint-config-google": "^0.14.0"
},
"type": "module"
+31 -31
View File
@@ -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
+33 -33
View File
@@ -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
View File
@@ -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"
}
+1 -1
View File
@@ -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()
};
}
+2 -1
View File
@@ -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
+2
View File
@@ -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