diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 43308ad9ba..f2cffe4b34 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -121,6 +121,12 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo super().clean() + if common.models.InvenTreeSetting.get_setting('BUILDORDER_REQUIRE_RESPONSIBLE'): + if not self.responsible: + raise ValidationError({ + 'responsible': _('Responsible user or group must be specified') + }) + # Prevent changing target part after creation if self.has_field_changed('part'): raise ValidationError({ diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 13a8bc19f8..f3e5066bde 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -148,7 +148,7 @@ class CurrencyExchangeView(APIView): response = { 'base_currency': common.models.InvenTreeSetting.get_setting( - 'INVENTREE_DEFAULT_CURRENCY', 'USD' + 'INVENTREE_DEFAULT_CURRENCY', backup_value='USD' ), 'exchange_rates': {}, 'updated': updated, diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 47d9bf47bc..8d5ac1a341 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -190,6 +190,8 @@ class BaseInvenTreeSetting(models.Model): SETTINGS: dict[str, SettingsKeyType] = {} + CHECK_SETTING_KEY = False + extra_unique_fields: list[str] = [] class Meta: @@ -286,16 +288,16 @@ class BaseInvenTreeSetting(models.Model): def save_to_cache(self): """Save this setting object to cache.""" - ckey = self.cache_key + key = self.cache_key # skip saving to cache if no pk is set if self.pk is None: return - logger.debug("Saving setting '%s' to cache", ckey) + logger.debug("Saving setting '%s' to cache", key) try: - cache.set(ckey, self, timeout=3600) + cache.set(key, self, timeout=3600) except Exception: pass @@ -559,8 +561,8 @@ class BaseInvenTreeSetting(models.Model): # Unless otherwise specified, attempt to create the setting create = kwargs.pop('create', True) - # Perform cache lookup by default - do_cache = kwargs.pop('cache', True) + # Specify if cache lookup should be performed + do_cache = kwargs.pop('cache', False) # Prevent saving to the database during data import if InvenTree.ready.isImportingData(): @@ -572,12 +574,12 @@ class BaseInvenTreeSetting(models.Model): create = False do_cache = False - ckey = cls.create_cache_key(key, **kwargs) + cache_key = cls.create_cache_key(key, **kwargs) if do_cache: try: # First attempt to find the setting object in the cache - cached_setting = cache.get(ckey) + cached_setting = cache.get(cache_key) if cached_setting is not None: return cached_setting @@ -635,6 +637,17 @@ class BaseInvenTreeSetting(models.Model): If it does not exist, return the backup value (default = None) """ + if ( + cls.CHECK_SETTING_KEY + and key not in cls.SETTINGS + and not key.startswith('_') + ): + logger.warning( + "get_setting: Setting key '%s' is not defined for class %s", + key, + str(cls), + ) + # If no backup value is specified, attempt to retrieve a "default" value if backup_value is None: backup_value = cls.get_setting_default(key, **kwargs) @@ -670,6 +683,17 @@ class BaseInvenTreeSetting(models.Model): change_user: User object (must be staff member to update a core setting) create: If True, create a new setting if the specified key does not exist. """ + if ( + cls.CHECK_SETTING_KEY + and key not in cls.SETTINGS + and not key.startswith('_') + ): + logger.warning( + "set_setting: Setting key '%s' is not defined for class %s", + key, + str(cls), + ) + if change_user is not None and not change_user.is_staff: return @@ -1199,6 +1223,8 @@ class InvenTreeSetting(BaseInvenTreeSetting): SETTINGS: dict[str, InvenTreeSettingsKeyType] + CHECK_SETTING_KEY = True + class Meta: """Meta options for InvenTreeSetting.""" @@ -1694,7 +1720,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'STOCK_DELETE_DEPLETED_DEFAULT': { 'name': _('Delete Depleted Stock'), 'description': _( - 'Determines default behaviour when a stock item is depleted' + 'Determines default behavior when a stock item is depleted' ), 'default': True, 'validator': bool, @@ -1766,6 +1792,20 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': 'BO-{ref:04d}', 'validator': build.validators.validate_build_order_reference_pattern, }, + 'BUILDORDER_REQUIRE_RESPONSIBLE': { + 'name': _('Require Responsible Owner'), + 'description': _('A responsible owner must be assigned to each order'), + 'default': False, + 'validator': bool, + }, + 'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': { + 'name': _('Block Until Tests Pass'), + 'description': _( + 'Prevent build outputs from being completed until all required tests pass' + ), + 'default': False, + 'validator': bool, + }, 'RETURNORDER_ENABLED': { 'name': _('Enable Return Orders'), 'description': _('Enable return order functionality in the user interface'), @@ -1780,6 +1820,12 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': 'RMA-{ref:04d}', 'validator': order.validators.validate_return_order_reference_pattern, }, + 'RETURNORDER_REQUIRE_RESPONSIBLE': { + 'name': _('Require Responsible Owner'), + 'description': _('A responsible owner must be assigned to each order'), + 'default': False, + 'validator': bool, + }, 'RETURNORDER_EDIT_COMPLETED_ORDERS': { 'name': _('Edit Completed Return Orders'), 'description': _( @@ -1796,6 +1842,12 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': 'SO-{ref:04d}', 'validator': order.validators.validate_sales_order_reference_pattern, }, + 'SALESORDER_REQUIRE_RESPONSIBLE': { + 'name': _('Require Responsible Owner'), + 'description': _('A responsible owner must be assigned to each order'), + 'default': False, + 'validator': bool, + }, 'SALESORDER_DEFAULT_SHIPMENT': { 'name': _('Sales Order Default Shipment'), 'description': _('Enable creation of default shipment with sales orders'), @@ -1818,6 +1870,12 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': 'PO-{ref:04d}', 'validator': order.validators.validate_purchase_order_reference_pattern, }, + 'PURCHASEORDER_REQUIRE_RESPONSIBLE': { + 'name': _('Require Responsible Owner'), + 'description': _('A responsible owner must be assigned to each order'), + 'default': False, + 'validator': bool, + }, 'PURCHASEORDER_EDIT_COMPLETED_ORDERS': { 'name': _('Edit Completed Purchase Orders'), 'description': _( @@ -2004,14 +2062,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': False, 'validator': bool, }, - 'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': { - 'name': _('Block Until Tests Pass'), - 'description': _( - 'Prevent build outputs from being completed until all required tests pass' - ), - 'default': False, - 'validator': bool, - }, 'TEST_STATION_DATA': { 'name': _('Enable Test Station Data'), 'description': _('Enable test station data collection for test results'), @@ -2054,7 +2104,9 @@ def label_printer_options(): class InvenTreeUserSetting(BaseInvenTreeSetting): - """An InvenTreeSetting object with a usercontext.""" + """An InvenTreeSetting object with a user context.""" + + CHECK_SETTING_KEY = True class Meta: """Meta options for InvenTreeUserSetting.""" @@ -2093,7 +2145,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'validator': bool, }, 'HOMEPAGE_BOM_REQUIRES_VALIDATION': { - 'name': _('Show unvalidated BOMs'), + 'name': _('Show invalid BOMs'), 'description': _('Show BOMs that await validation on the homepage'), 'default': False, 'validator': bool, @@ -2406,6 +2458,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'validator': [int], 'default': '', }, + 'DEFAULT_LINE_LABEL_TEMPLATE': { + 'name': _('Default build line label template'), + 'description': _( + 'The build line label template to be automatically selected' + ), + 'validator': [int], + 'default': '', + }, 'NOTIFICATION_ERROR_REPORT': { 'name': _('Receive error reports'), 'description': _('Receive notifications for system errors'), @@ -2616,7 +2676,7 @@ class VerificationMethod(Enum): class WebhookEndpoint(models.Model): - """Defines a Webhook entdpoint. + """Defines a Webhook endpoint. Attributes: endpoint_id: Path to the webhook, @@ -2951,7 +3011,7 @@ class NewsFeedEntry(models.Model): - published: Date of publishing of the news item - author: Author of news item - summary: Summary of the news items content - - read: Was this iteam already by a superuser? + - read: Was this item already by a superuser? """ feed_id = models.CharField(verbose_name=_('Id'), unique=True, max_length=250) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index ca550b19f9..e990163c25 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -207,6 +207,8 @@ class Order( responsible: User (or group) responsible for managing the order """ + REQUIRE_RESPONSIBLE_SETTING = None + class Meta: """Metaclass options. Abstract ensures no database table is created.""" @@ -227,6 +229,16 @@ class Order( """Custom clean method for the generic order class.""" super().clean() + # Check if a responsible owner is required for this order type + if self.REQUIRE_RESPONSIBLE_SETTING: + if common_models.InvenTreeSetting.get_setting( + self.REQUIRE_RESPONSIBLE_SETTING, backup_value=False + ): + if not self.responsible: + raise ValidationError({ + 'responsible': _('Responsible user or group must be specified') + }) + # Check that the referenced 'contact' matches the correct 'company' if self.company and self.contact: if self.contact.company != self.company: @@ -347,6 +359,9 @@ class PurchaseOrder(TotalPriceMixin, Order): target_date: Expected delivery target date for PurchaseOrder completion (optional) """ + REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' + REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE' + def get_absolute_url(self): """Get the 'web' URL for this order.""" if settings.ENABLE_CLASSIC_FRONTEND: @@ -372,9 +387,6 @@ class PurchaseOrder(TotalPriceMixin, Order): return defaults - # Global setting for specifying reference pattern - REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' - @staticmethod def filterByDate(queryset, min_date, max_date): """Filter by 'minimum and maximum date range'. @@ -805,6 +817,9 @@ class PurchaseOrder(TotalPriceMixin, Order): class SalesOrder(TotalPriceMixin, Order): """A SalesOrder represents a list of goods shipped outwards to a customer.""" + REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN' + REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE' + def get_absolute_url(self): """Get the 'web' URL for this order.""" if settings.ENABLE_CLASSIC_FRONTEND: @@ -828,9 +843,6 @@ class SalesOrder(TotalPriceMixin, Order): return defaults - # Global setting for specifying reference pattern - REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN' - @staticmethod def filterByDate(queryset, min_date, max_date): """Filter by "minimum and maximum date range". @@ -1943,6 +1955,9 @@ class ReturnOrder(TotalPriceMixin, Order): status: The status of the order (refer to status_codes.ReturnOrderStatus) """ + REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN' + REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE' + def get_absolute_url(self): """Get the 'web' URL for this order.""" if settings.ENABLE_CLASSIC_FRONTEND: @@ -1968,8 +1983,6 @@ class ReturnOrder(TotalPriceMixin, Order): return defaults - REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN' - def __str__(self): """Render a string representation of this ReturnOrder.""" return f"{self.reference} - {self.customer.name if self.customer else _('no customer')}" diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index c00f577b07..d9f63d67c0 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -13,6 +13,7 @@ from djmoney.money import Money from icalendar import Calendar from rest_framework import status +from common.models import InvenTreeSetting from common.settings import currency_codes from company.models import Company, SupplierPart, SupplierPriceBreak from InvenTree.status_codes import ( @@ -27,6 +28,7 @@ from InvenTree.unit_test import InvenTreeAPITestCase from order import models from part.models import Part from stock.models import StockItem +from users.models import Owner class OrderTest(InvenTreeAPITestCase): @@ -347,15 +349,35 @@ class PurchaseOrderTest(OrderTest): """Test that we can create a new PurchaseOrder via the API.""" self.assignRole('purchase_order.add') - self.post( - reverse('api-po-list'), - { - 'reference': 'PO-12345678', - 'supplier': 1, - 'description': 'A test purchase order', - }, - expected_code=201, - ) + setting = 'PURCHASEORDER_REQUIRE_RESPONSIBLE' + url = reverse('api-po-list') + + InvenTreeSetting.set_setting(setting, False) + + data = { + 'reference': 'PO-12345678', + 'supplier': 1, + 'description': 'A test purchase order', + } + + self.post(url, data, expected_code=201) + + # Check the 'responsible required' field + InvenTreeSetting.set_setting(setting, True) + + data['reference'] = 'PO-12345679' + data['responsible'] = None + + response = self.post(url, data, expected_code=400) + + self.assertIn('Responsible user or group must be specified', str(response.data)) + + data['responsible'] = Owner.objects.first().pk + + response = self.post(url, data, expected_code=201) + + # Revert the setting to previous value + InvenTreeSetting.set_setting(setting, False) def test_po_creation_date(self): """Test that we can create set the creation_date field of PurchaseOrder via the API.""" diff --git a/InvenTree/part/helpers.py b/InvenTree/part/helpers.py index 3da875d3da..ab20c795d7 100644 --- a/InvenTree/part/helpers.py +++ b/InvenTree/part/helpers.py @@ -25,7 +25,9 @@ def compile_full_name_template(*args, **kwargs): global _part_full_name_template global _part_full_name_template_string - template_string = InvenTreeSetting.get_setting('PART_NAME_FORMAT', '') + template_string = InvenTreeSetting.get_setting( + 'PART_NAME_FORMAT', backup_value='', cache=True + ) # Skip if the template string has not changed if ( diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 216d424066..9459ee3bae 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -491,7 +491,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase): PartCategory.objects.rebuild() - with self.assertNumQueriesLessThan(10): + with self.assertNumQueriesLessThan(12): response = self.get(reverse('api-part-category-tree'), expected_code=200) self.assertEqual(len(response.data), PartCategory.objects.count()) diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 42de5d7b8f..a72cf06525 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -446,7 +446,7 @@ class StockLocationTest(StockAPITestCase): StockLocation.objects.rebuild() - with self.assertNumQueriesLessThan(10): + with self.assertNumQueriesLessThan(12): response = self.get(reverse('api-location-tree'), expected_code=200) self.assertEqual(len(response.data), StockLocation.objects.count()) diff --git a/InvenTree/templates/InvenTree/settings/build.html b/InvenTree/templates/InvenTree/settings/build.html index 764e333621..781bb71525 100644 --- a/InvenTree/templates/InvenTree/settings/build.html +++ b/InvenTree/templates/InvenTree/settings/build.html @@ -13,6 +13,7 @@