Order responsible requirement (#6866)

* Add BUILDORDER_REQUIRE_RESPONSIBLE setting

- If set, build orders must specify a responsible owner

* Add responsible required setting to other order models:

- PurchaseOrder
- SalesOrder
- ReturnOrder

* Add unit test

* Adjust unit tests

* Settings updates:

- Only check settings for global and user settings
- Plugin settings are not defined at run-time

* typo fix

* More spelling fixes

* Specify responsible owner pk
This commit is contained in:
Oliver 2024-03-27 15:25:56 +11:00 committed by GitHub
parent 785b3b0e68
commit cd0d35047d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 152 additions and 41 deletions

View File

@ -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({

View File

@ -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,

View File

@ -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)

View File

@ -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')}"

View File

@ -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."""

View File

@ -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 (

View File

@ -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())

View File

@ -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())

View File

@ -13,6 +13,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PATTERN" %}
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REQUIRE_RESPONSIBLE" %}
{% include "InvenTree/settings/setting.html" with key="PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS" %}
</tbody>
</table>

View File

@ -11,6 +11,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PATTERN" %}
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REQUIRE_RESPONSIBLE" %}
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_EDIT_COMPLETED_ORDERS" icon='fa-edit' %}
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_AUTO_COMPLETE" icon='fa-check-circle' %}
</tbody>

View File

@ -13,6 +13,7 @@
<tbody>
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_ENABLED" icon="fa-check-circle" %}
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_REFERENCE_PATTERN" %}
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_REQUIRE_RESPONSIBLE" %}
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_EDIT_COMPLETED_ORDERS" icon="fa-edit" %}
</tbody>
</table>

View File

@ -12,6 +12,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PATTERN" %}
{% include "InvenTree/settings/setting.html" with key="SALESORDER_REQUIRE_RESPONSIBLE" %}
{% include "InvenTree/settings/setting.html" with key="SALESORDER_DEFAULT_SHIPMENT" icon="fa-truck-loading" %}
{% include "InvenTree/settings/setting.html" with key="SALESORDER_EDIT_COMPLETED_ORDERS" icon='fa-edit' %}
</tbody>

View File

@ -231,6 +231,7 @@ export default function SystemSettings() {
<GlobalSettingList
keys={[
'BUILDORDER_REFERENCE_PATTERN',
'BUILDORDER_REQUIRE_RESPONSIBLE',
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS'
]}
/>
@ -244,6 +245,7 @@ export default function SystemSettings() {
<GlobalSettingList
keys={[
'PURCHASEORDER_REFERENCE_PATTERN',
'PURCHASEORDER_REQUIRE_RESPONSIBLE',
'PURCHASEORDER_EDIT_COMPLETED_ORDERS',
'PURCHASEORDER_AUTO_COMPLETE'
]}
@ -258,6 +260,7 @@ export default function SystemSettings() {
<GlobalSettingList
keys={[
'SALESORDER_REFERENCE_PATTERN',
'SALESORDER_REQUIRE_RESPONSIBLE',
'SALESORDER_DEFAULT_SHIPMENT',
'SALESORDER_EDIT_COMPLETED_ORDERS'
]}
@ -273,6 +276,7 @@ export default function SystemSettings() {
keys={[
'RETURNORDER_ENABLED',
'RETURNORDER_REFERENCE_PATTERN',
'RETURNORDER_REQUIRE_RESPONSIBLE',
'RETURNORDER_EDIT_COMPLETED_ORDERS'
]}
/>