diff --git a/docs/docs/manufacturing/build.md b/docs/docs/manufacturing/build.md index 7652732091..2e5ef73210 100644 --- a/docs/docs/manufacturing/build.md +++ b/docs/docs/manufacturing/build.md @@ -285,6 +285,7 @@ The following [global settings](../settings/global.md) are available for adjusti | Name | Description | Default | Units | | ---- | ----------- | ------- | ----- | +{{ globalsetting("BUILDORDER_ENABLED") }} {{ globalsetting("BUILDORDER_REFERENCE_PATTERN") }} {{ globalsetting("BUILDORDER_REQUIRE_RESPONSIBLE") }} {{ globalsetting("BUILDORDER_REQUIRE_ACTIVE_PART") }} diff --git a/docs/docs/purchasing/purchase_order.md b/docs/docs/purchasing/purchase_order.md index 09ecf1070f..4506a683c8 100644 --- a/docs/docs/purchasing/purchase_order.md +++ b/docs/docs/purchasing/purchase_order.md @@ -206,6 +206,7 @@ The following [global settings](../settings/global.md) are available for purchas | Name | Description | Default | Units | | ---- | ----------- | ------- | ----- | +{{ globalsetting("PURCHASEORDER_ENABLED") }} {{ globalsetting("PURCHASEORDER_REFERENCE_PATTERN") }} {{ globalsetting("PURCHASEORDER_REQUIRE_RESPONSIBLE") }} {{ globalsetting("PURCHASEORDER_CONVERT_CURRENCY") }} diff --git a/docs/docs/sales/sales_order.md b/docs/docs/sales/sales_order.md index 1e1a65de70..da2e6005cb 100644 --- a/docs/docs/sales/sales_order.md +++ b/docs/docs/sales/sales_order.md @@ -287,6 +287,7 @@ The following [global settings](../settings/global.md) are available for sales o | Name | Description | Default | Units | | ---- | ----------- | ------- | ----- | +{{ globalsetting("SALESORDER_ENABLED") }} {{ globalsetting("SALESORDER_REFERENCE_PATTERN") }} {{ globalsetting("SALESORDER_REQUIRE_RESPONSIBLE") }} {{ globalsetting("SALESORDER_DEFAULT_SHIPMENT") }} diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c97883c52a..1f3a122447 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 545 +INVENTREE_API_VERSION = 546 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v546 -> 2026-09-12 : https://github.com/inventree/InvenTree/pull/12842/changes + - Added setting flags to the settings APIs (read-only) + v545 -> 2026-09-08 : https://github.com/inventree/InvenTree/pull/12808 - Ensure consistent ordering of SSO options in API documentation diff --git a/src/backend/InvenTree/InvenTree/auth_overrides.py b/src/backend/InvenTree/InvenTree/auth_overrides.py index b695f977f8..bf590d0500 100644 --- a/src/backend/InvenTree/InvenTree/auth_overrides.py +++ b/src/backend/InvenTree/InvenTree/auth_overrides.py @@ -149,7 +149,6 @@ class RegistrationMixin: def save_user(self, request, user, form, commit=True): """Check if a default group is set in settings.""" - user._is_registering = True # marker for email synchronization # Create the user user = super().save_user(request, user, form) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 04d05461b7..75512841d3 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -1013,6 +1013,14 @@ class BaseInvenTreeSetting(models.Model): return setting.get('confirm_text', '') + def flags(self) -> list: + """Return the flags associated with this setting.""" + setting = self.get_setting_definition( + self.key, **self.get_filters_for_instance() + ) + + return setting.get('flags', []) + def model_filters(self) -> Optional[dict]: """Return the model filters associated with this setting.""" setting = self.get_setting_definition( diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 8a3d785e99..606e4d579e 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -132,6 +132,12 @@ class SettingsSerializer(InvenTreeModelSerializer): confirm_text = serializers.CharField(read_only=True) + flags = serializers.ListField( + child=serializers.CharField(), + read_only=True, + help_text=_('Indicating behavior or purpose of setting.'), + ) + def is_valid(self, *, raise_exception=False): """Validate the setting, including confirmation if required.""" ret = super().is_valid(raise_exception=raise_exception) @@ -169,6 +175,7 @@ class GlobalSettingsSerializer(SettingsSerializer): 'read_only', 'confirm', 'confirm_text', + 'flags', ] read_only = serializers.SerializerMethodField( @@ -214,6 +221,7 @@ class UserSettingsSerializer(SettingsSerializer): 'typ', 'confirm', 'confirm_text', + 'flags', ] user = serializers.PrimaryKeyRelatedField(read_only=True) @@ -264,6 +272,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer): 'required', 'confirm', 'confirm_text', + 'flags', ] # set Meta class diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index abdfd55094..79cbb4fd06 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -18,7 +18,7 @@ import common.currency import common.validators import order.validators import report.helpers -from common.setting.type import InvenTreeSettingsKeyType +from common.setting.type import InvenTreeSettingsKeyType, SettingFlag def validate_part_name_format(value): @@ -60,41 +60,40 @@ def validate_part_name_format(value): return True -def update_instance_name(setting): - """Update the first site objects name to instance name.""" +def _get_site_object(): + """Return the first site object, or None if it doesn't exist.""" if not django_settings.SITE_MULTI: - return + return # pragma: no cover try: from django.contrib.sites.models import Site except (ImportError, RuntimeError): # Multi-site support not enabled - return + return # pragma: no cover site_obj = Site.objects.all().order_by('id').first() - site_obj.name = setting.value - site_obj.save() + if site_obj is None: + return # pragma: no cover + return site_obj + + +def update_instance_name(setting): + """Update the first site objects name to instance name.""" + if site_obj := _get_site_object(): + site_obj.name = setting.value + site_obj.save() def update_instance_url(setting): """Update the first site objects domain to url.""" - if not django_settings.SITE_MULTI: - return - - try: - from django.contrib.sites.models import Site - except (ImportError, RuntimeError): - # Multi-site support not enabled - return - - site_obj = Site.objects.all().order_by('id').first() - site_obj.domain = setting.value - site_obj.save() + if site_obj := _get_site_object(): + site_obj.domain = setting.value + site_obj.save() def settings_group_options(): """Build up group tuple for settings based on your choices.""" - return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]] + return [('', _('No group')), *[(str(a.pk), str(a)) for a in Group.objects.all()]] def reload_plugin_registry(setting): @@ -189,12 +188,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, 'hidden': True, + 'flags': [SettingFlag.INTERNAL], }, '_PENDING_MIGRATIONS': { 'name': _('Pending migrations'), 'description': _('Number of pending database migrations'), 'default': 0, 'validator': int, + 'flags': [SettingFlag.INTERNAL], }, SystemSetId.GLOBAL_WARNING: { 'name': _('Active warning codes'), @@ -202,6 +203,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'validator': json.loads, 'default': '{}', 'hidden': True, + 'flags': [SettingFlag.INTERNAL], }, 'INVENTREE_INSTANCE_ID': { 'name': _('Instance ID'), @@ -240,6 +242,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Show a warning banner in the UI when logged in as superuser'), 'validator': bool, 'default': True, + 'flags': [SettingFlag.SECURITY], }, 'INVENTREE_SHOW_ADMIN_BANNER': { 'name': _('Show admin banner'), @@ -292,12 +295,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'units': 'MB', 'default': 10, 'validator': [int, MinValueValidator(1)], + 'flags': [SettingFlag.SECURITY], }, 'INVENTREE_STRICT_URLS': { 'name': _('Strict URL Validation'), 'description': _('Require schema specification when validating URLs'), 'validator': bool, 'default': True, + 'flags': [SettingFlag.SECURITY], }, 'INVENTREE_UPDATE_CHECK_INTERVAL': { 'name': _('Update Check Interval'), @@ -364,12 +369,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable barcode scanner support in the web interface'), 'default': True, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'BARCODE_STORE_RESULTS': { 'name': _('Store Barcode Results'), 'description': _('Store barcode scan results in the database'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'BARCODE_RESULTS_MAX_NUM': { 'name': _('Barcode Scans Maximum Count'), @@ -389,12 +396,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Allow barcode scanning via webcam in browser'), 'default': True, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'BARCODE_SHOW_TEXT': { 'name': _('Barcode Show Data'), 'description': _('Display barcode data in browser as text'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'BARCODE_GENERATION_PLUGIN': { 'name': _('Barcode Generation Plugin'), @@ -407,12 +416,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable locking of parts to prevent modification'), 'validator': bool, 'default': True, + 'flags': [SettingFlag.TOGGLE], }, 'PART_ENABLE_REVISION': { 'name': _('Part Revisions'), 'description': _('Enable revision field for Part'), 'validator': bool, 'default': True, + 'flags': [SettingFlag.TOGGLE], }, 'PART_REVISION_ASSEMBLY_ONLY': { 'name': _('Assembly Revision Only'), @@ -656,6 +667,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable label printing from the web interface'), 'default': True, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'LABEL_DPI': { 'name': _('Label Image DPI'), @@ -670,24 +682,28 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable generation of reports'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'REPORT_DEBUG_MODE': { 'name': _('Debug Mode'), 'description': _('Generate reports in debug mode (HTML output)'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.SECURITY], }, 'REPORT_FETCH_URLS': { 'name': _('Report URL Fetching'), 'description': _('Allow fetching of remote URLs when generating reports'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.SECURITY], }, 'REPORT_LOG_ERRORS': { 'name': _('Log Report Errors'), 'description': _('Log errors which occur when generating reports'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.SECURITY], }, 'REPORT_DEFAULT_PAGE_SIZE': { 'name': _('Page Size'), @@ -737,6 +753,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable stock expiry functionality'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'STOCK_ALLOW_EXPIRED_SALE': { 'name': _('Sell Expired Stock'), @@ -764,6 +781,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable ownership control over stock locations and items'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'STOCK_LOCATION_DEFAULT_ICON': { 'name': _('Stock Location Default Icon'), @@ -866,6 +884,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable return order functionality in the user interface'), 'validator': bool, 'default': False, + 'flags': [SettingFlag.TOGGLE], }, 'RETURNORDER_REFERENCE_PATTERN': { 'name': _('Return Order Reference Pattern'), @@ -936,6 +955,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable transfer order functionality in the user interface'), 'validator': bool, 'default': False, + 'flags': [SettingFlag.TOGGLE], }, 'TRANSFERORDER_REFERENCE_PATTERN': { 'name': _('Transfer Order Reference Pattern'), @@ -1017,18 +1037,21 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable password forgot function on the login pages'), 'default': True, 'validator': bool, + 'flags': [SettingFlag.SECURITY], }, 'LOGIN_ENABLE_REG': { 'name': _('Enable registration'), 'description': _('Enable self-registration for users on the login pages'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.SECURITY], }, 'LOGIN_ENABLE_SSO': { 'name': _('Enable SSO'), 'description': _('Enable SSO on the login pages'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.SECURITY], }, 'LOGIN_ENABLE_SSO_REG': { 'name': _('Enable SSO registration'), @@ -1037,6 +1060,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { ), 'default': False, 'validator': bool, + 'flags': [SettingFlag.SECURITY], }, 'LOGIN_ENABLE_SSO_GROUP_SYNC': { 'name': _('Enable SSO group sync'), @@ -1045,12 +1069,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { ), 'default': False, 'validator': bool, + 'flags': [SettingFlag.SECURITY], }, 'SSO_GROUP_KEY': { 'name': _('SSO group key'), 'description': _('The name of the groups claim attribute provided by the IdP'), 'default': 'groups', 'validator': str, + 'flags': [SettingFlag.SECURITY], }, 'SSO_GROUP_MAP': { 'name': _('SSO group map'), @@ -1058,6 +1084,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'A mapping from SSO groups to local InvenTree groups. If the local group does not exist, it will be created.' ), 'validator': json.loads, + 'flags': [SettingFlag.SECURITY], 'default': '{}', }, 'SSO_REMOVE_GROUPS': { @@ -1067,6 +1094,11 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { ), 'default': True, 'validator': bool, + 'confirm': True, + 'confirm_text': _( + 'Disabling this setting will stop cleaning up groups that external users no longer belong to. This might cause security issues.' + ), + 'flags': [SettingFlag.SECURITY], }, 'LOGIN_MAIL_REQUIRED': { 'name': _('Email required'), @@ -1099,6 +1131,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { ), 'default': '', 'before_save': common.validators.validate_email_domains, + 'flags': [SettingFlag.SECURITY], }, 'SIGNUP_GROUP': { 'name': _('Group on signup'), @@ -1107,6 +1140,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { ), 'default': '', 'choices': settings_group_options, + 'flags': [SettingFlag.SECURITY], }, 'LOGIN_ENFORCE_MFA': { 'name': _('Enforce MFA'), @@ -1117,6 +1151,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'confirm_text': _( 'Enabling this setting will require all users to set up multifactor authentication. All sessions will be disconnected immediately.' ), + 'flags': [SettingFlag.SECURITY], 'after_save': enforce_mfa, }, 'PLUGIN_ON_STARTUP': { @@ -1141,6 +1176,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, 'after_save': reload_plugin_registry, + 'flags': [SettingFlag.TOGGLE], }, 'ENABLE_PLUGINS_NAVIGATION': { 'name': _('Enable navigation integration'), @@ -1148,6 +1184,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, 'after_save': reload_plugin_registry, + 'flags': [SettingFlag.TOGGLE], }, 'ENABLE_PLUGINS_APP': { 'name': _('Enable app integration'), @@ -1155,6 +1192,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, 'after_save': reload_plugin_registry, + 'flags': [SettingFlag.TOGGLE], }, 'ENABLE_PLUGINS_SCHEDULE': { 'name': _('Enable schedule integration'), @@ -1162,6 +1200,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, 'after_save': reload_plugin_registry, + 'flags': [SettingFlag.TOGGLE], }, 'ENABLE_PLUGINS_EVENTS': { 'name': _('Enable event integration'), @@ -1169,6 +1208,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, 'after_save': reload_plugin_registry, + 'flags': [SettingFlag.TOGGLE], }, 'ENABLE_PLUGINS_INTERFACE': { 'name': _('Enable interface integration'), @@ -1176,6 +1216,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, 'after_save': reload_plugin_registry, + 'flags': [SettingFlag.TOGGLE], }, 'ENABLE_PLUGINS_MAILS': { 'name': _('Enable mail integration'), @@ -1183,18 +1224,21 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, 'after_save': reload_plugin_registry, + 'flags': [SettingFlag.TOGGLE], }, 'PROJECT_CODES_ENABLED': { 'name': _('Enable project codes'), 'description': _('Enable project codes for tracking projects'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'STOCKTAKE_ENABLE': { 'name': _('Enable Stocktake'), 'description': _( 'Enable functionality for recording historical stock levels and value' ), + 'flags': [SettingFlag.TOGGLE], 'validator': bool, 'default': False, }, @@ -1287,6 +1331,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'description': _('Enable test station data collection for test results'), 'default': False, 'validator': bool, + 'flags': [SettingFlag.TOGGLE], }, 'MACHINE_PING_ENABLED': { 'name': _('Enable Machine Ping'), @@ -1296,4 +1341,25 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': True, 'validator': bool, }, + 'SALESORDER_ENABLED': { + 'name': _('Enable Sales Orders'), + 'description': _('Enable sales order functionality in the user interface'), + 'validator': bool, + 'default': True, + 'flags': [SettingFlag.TOGGLE], + }, + 'PURCHASEORDER_ENABLED': { + 'name': _('Enable Purchase Orders'), + 'description': _('Enable purchase order functionality in the user interface'), + 'validator': bool, + 'default': True, + 'flags': [SettingFlag.TOGGLE], + }, + 'BUILDORDER_ENABLED': { + 'name': _('Enable Build Orders'), + 'description': _('Enable build order functionality in the user interface'), + 'validator': bool, + 'default': True, + 'flags': [SettingFlag.TOGGLE], + }, } diff --git a/src/backend/InvenTree/common/setting/tests.py b/src/backend/InvenTree/common/setting/tests.py index 5a0088cc73..c7dc3f6f19 100644 --- a/src/backend/InvenTree/common/setting/tests.py +++ b/src/backend/InvenTree/common/setting/tests.py @@ -1,7 +1,7 @@ """Tests for the various validators in the settings.""" from django.core.exceptions import ValidationError -from django.test import TestCase +from django.test import TestCase, override_settings import common.setting.system @@ -48,3 +48,27 @@ class SettingsValidatorTests(TestCase): def test_update_instance_url_no_multi(self): """Test update_instance_url.""" self.assertIsNone(common.setting.system.update_instance_url('abc.com')) + + @override_settings(SITE_URL=None) + def test_base_url_validator(self): + """Test valid and invalid base URL values.""" + validator = common.setting.system.BaseURLValidator() + + for value in ['', 'http://localhost', 'https://inventree.example']: + self.assertIsNone(validator(value)) + + for value in ['inventree', 'ftp://inventree']: + with self.assertRaises(ValidationError): + validator(value) + + @override_settings(SITE_URL='https://inventree.example') + def test_base_url_validator_locked_by_configuration(self): + """Test that a configured site URL cannot be changed.""" + validator = common.setting.system.BaseURLValidator() + + self.assertIsNone(validator('https://inventree.example')) + + with self.assertRaisesMessage( + ValidationError, 'Site URL is locked by configuration' + ): + validator('https://other.example') diff --git a/src/backend/InvenTree/common/setting/type.py b/src/backend/InvenTree/common/setting/type.py index 3a704d5e24..39a2c29d5b 100644 --- a/src/backend/InvenTree/common/setting/type.py +++ b/src/backend/InvenTree/common/setting/type.py @@ -1,7 +1,28 @@ """Types for settings.""" from collections.abc import Callable -from typing import Any, NotRequired, TypedDict +from enum import StrEnum + +# only import for type checking +from typing import TYPE_CHECKING, Any, NotRequired, Optional, TypedDict + +if TYPE_CHECKING: + from django_stubs_ext import StrOrPromise +else: + StrOrPromise = str + + +# enum to mark what kind of behavior a setting might influence; these are not for enforcing a specific logic but mainly docs / warning messages +# these are NOT a security boundary +class SettingFlag(StrEnum): + """Flags to indicate the behavior or purpose of a setting.""" + + """Setting influences visibility or UI of major functionality.""" + TOGGLE = 'org.inventree.settingsflag.function_toggle' + """Setting is for internal use only and should not be exposed to end users.""" + INTERNAL = 'org.inventree.settingsflag.internal' + """Setting has security implications and should be handled with care.""" + SECURITY = 'org.inventree.settingsflag.security' class SettingsKeyType(TypedDict, total=False): @@ -23,14 +44,19 @@ class SettingsKeyType(TypedDict, total=False): model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional) confirm: Require an explicit confirmation before changing the setting (optional, default: False) confirm_text: Text to display in the confirmation dialog (optional) + flags: List of SettingFlag indicating the behavior or purpose of the setting (optional) """ - name: str - description: str - units: str + name: StrOrPromise + description: StrOrPromise + units: StrOrPromise validator: Callable | list[Callable] | tuple[Callable] default: Callable | Any - choices: list[tuple[str, str]] | Callable[[], list[tuple[str, str]]] + choices: ( + list[tuple[str, StrOrPromise]] + | Callable[[], list[tuple[str, StrOrPromise]] | None] + | None + ) model_filters: dict[str, Any] hidden: bool before_save: Callable[..., None] @@ -39,7 +65,8 @@ class SettingsKeyType(TypedDict, total=False): required: bool model: str confirm: bool - confirm_text: str + confirm_text: StrOrPromise + flags: Optional[list[SettingFlag]] class InvenTreeSettingsKeyType(SettingsKeyType): diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 65d939c5ed..1f9fe9935c 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -459,6 +459,7 @@ class SettingsTest(InvenTreeTestCase): 'before_save', 'confirm', 'confirm_text', + 'flags', ] for k in setting: diff --git a/src/backend/InvenTree/report/helpers.py b/src/backend/InvenTree/report/helpers.py index 0b174fb023..b629560d56 100644 --- a/src/backend/InvenTree/report/helpers.py +++ b/src/backend/InvenTree/report/helpers.py @@ -4,11 +4,16 @@ import base64 import io import logging import mimetypes +from typing import TYPE_CHECKING from django.utils.translation import gettext_lazy as _ from common.settings import get_global_setting +if TYPE_CHECKING: + from django_stubs_ext import StrOrPromise +else: + StrOrPromise = str logger = logging.getLogger('inventree') @@ -38,7 +43,7 @@ def report_model_options(): ] -def report_page_size_options(): +def report_page_size_options() -> list[tuple[str, StrOrPromise]]: """Returns a list of page size options for PDF reports.""" return [ ('A4', _('A4')), diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index 5b922c1e19..d37bf5734e 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -46,6 +46,28 @@ User.add_to_class('__str__', user_model_str) # Overriding User.__str__ # OVERRIDE END +if settings.LDAP_AUTH: # pragma: no cover + from django_auth_ldap.backend import populate_user # ty: ignore[unresolved-import] + + @receiver(populate_user) + def create_email_address(user, **kwargs): + """If a django user is from LDAP and has an email attached to it, create an allauth email address for them automatically. + + https://django-auth-ldap.readthedocs.io/en/latest/users.html#populating-users + https://django-auth-ldap.readthedocs.io/en/latest/reference.html#django_auth_ldap.backend.populate_user + """ + # User must exist in the database before we can create their EmailAddress. By their recommendation, + # we can just call .save() now + user.save() + + # if they got an email address from LDAP, create it now and make it the primary + if ( + user.email + and not EmailAddress.objects.filter(user=user, email=user.email).exists() + ): + EmailAddress.objects.create(user=user, email=user.email, primary=True) + + def default_token(): """Generate a default value for the token.""" return ApiToken.generate_key() @@ -625,30 +647,3 @@ def validate_primary_group_on_group_change(sender, instance, action, **kwargs): if profile.primary_group and profile.primary_group not in instance.groups.all(): profile.primary_group = None profile.save() - - -# update allauth user mail -@receiver(post_save, sender=User) -def sync_user_email_address(sender, instance: User, created: bool, **kwargs): - """Keep the allauth EmailAddress in sync with User email field.""" - # Are we currently in the API path of user registration? - if getattr(instance, '_is_registering', False): - return - - if isImportingData() or isReadOnlyCommand(): - return - - if not instance.email: - return - - primary_address = EmailAddress.objects.filter(user=instance, primary=True).first() - - if primary_address: - if primary_address.email != instance.email: - primary_address.email = instance.email - primary_address.verified = False - primary_address.save() - elif not EmailAddress.objects.filter(user=instance, email=instance.email).exists(): - EmailAddress.objects.create( - user=instance, email=instance.email, primary=True, verified=False - ) diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 6b13f4577c..1d0b20064c 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -514,6 +514,8 @@ class UserCreateSerializer(ExtendedUserSerializer): def create(self, validated_data): """Send an e email to the user after creation.""" + from allauth.account.models import EmailAddress + from InvenTree.helpers_model import get_base_url from InvenTree.tasks import email_user, offload_task @@ -521,6 +523,12 @@ class UserCreateSerializer(ExtendedUserSerializer): instance = super().create(validated_data) + # Create the EmailAddress entry for the user + if instance.email: + EmailAddress.objects.create( + user=instance, email=instance.email, primary=True, verified=False + ) + # Make sure the user cannot login until they have set a password instance.set_unusable_password() diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index 12fab15ff2..6861c5f08a 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -125,6 +125,11 @@ class UserAPITests(InvenTreeAPITestCase): self.assertEqual(response.data['is_staff'], False) self.assertEqual(response.data['is_superuser'], False) self.assertEqual(response.data['is_active'], True) + self.assertTrue( + EmailAddress.objects.filter( + user__username=data['username'], email=data['email'], primary=True + ).exists() + ) # Try to adjust the 'is_superuser' field # Only a "superuser" can set this field @@ -372,26 +377,6 @@ class SuperuserAPITests(InvenTreeAPITestCase): resp = self.put(url, {'password': 'inventree'}, expected_code=200) self.assertEqual(resp.data, {}) - def test_email_address_sync_signal(self): - """Test emailadress sync.""" - user = User.objects.create(username='start', email='start@example.org') - self.assertTrue( - EmailAddress.objects.filter( - user=user, email='start@example.org', primary=True - ).exists() - ) - - # change should trigger emailaddress update - user.email = 'updated@example.org' - user.save() - - self.assertFalse( - EmailAddress.objects.filter(user=user, email='start@example.org').exists() - ) - updated = EmailAddress.objects.get(user=user, primary=True) - self.assertEqual(updated.email, 'updated@example.org') - self.assertFalse(updated.verified) - class UserTokenTests(InvenTreeAPITestCase): """Tests for user token functionality.""" diff --git a/src/frontend/lib/enums/Roles.tsx b/src/frontend/lib/enums/Roles.tsx index 3d7ff9c78b..6e795ea9d9 100644 --- a/src/frontend/lib/enums/Roles.tsx +++ b/src/frontend/lib/enums/Roles.tsx @@ -53,3 +53,11 @@ export function userRoleLabel(role: UserRoles): string { return role as string; } } + +export const roleToViewSettingMap: Partial> = { + [UserRoles.build]: 'BUILDORDER_ENABLED', + [UserRoles.sales_order]: 'SALESORDER_ENABLED', + [UserRoles.purchase_order]: 'PURCHASEORDER_ENABLED', + [UserRoles.transfer_order]: 'TRANSFERORDER_ENABLED', + [UserRoles.return_order]: 'RETURNORDER_ENABLED' +}; diff --git a/src/frontend/lib/types/Settings.tsx b/src/frontend/lib/types/Settings.tsx index 0c89b19883..2efd7b2dc2 100644 --- a/src/frontend/lib/types/Settings.tsx +++ b/src/frontend/lib/types/Settings.tsx @@ -16,6 +16,12 @@ export enum SettingType { Model = 'related field' } +export enum SettingFlag { + TOGGLE = 'org.inventree.settingsflag.function_toggle', + INTERNAL = 'org.inventree.settingsflag.internal', + SECURITY = 'org.inventree.settingsflag.security' +} + // Type interface defining a single 'setting' object export interface Setting { pk: number; @@ -36,6 +42,7 @@ export interface Setting { read_only?: boolean; confirm?: boolean; confirm_text?: string; + flags: SettingFlag[]; } export interface SettingChoice { diff --git a/src/frontend/lib/types/User.tsx b/src/frontend/lib/types/User.tsx index 8194ecdfbc..3a63fbabf1 100644 --- a/src/frontend/lib/types/User.tsx +++ b/src/frontend/lib/types/User.tsx @@ -47,6 +47,7 @@ export interface UserStateProps { hasChangeRole: (role: UserRoles) => boolean; hasAddRole: (role: UserRoles) => boolean; hasViewRole: (role: UserRoles) => boolean; + hasViewVisible: (role: UserRoles) => boolean; checkUserPermission: ( model: ModelType, permission: UserPermissions diff --git a/src/frontend/src/components/nav/NavigationDrawer.tsx b/src/frontend/src/components/nav/NavigationDrawer.tsx index a3faa163b1..6a209d8196 100644 --- a/src/frontend/src/components/nav/NavigationDrawer.tsx +++ b/src/frontend/src/components/nav/NavigationDrawer.tsx @@ -83,21 +83,21 @@ function DrawerContent({ closeFunc }: Readonly<{ closeFunc?: () => void }>) { id: 'build', title: t`Manufacturing`, link: '/manufacturing/', - hidden: !user.hasViewRole(UserRoles.build), + hidden: !user.hasViewVisible(UserRoles.build), icon: 'build' }, { id: 'purchasing', title: t`Purchasing`, link: '/purchasing/', - hidden: !user.hasViewRole(UserRoles.purchase_order), + hidden: !user.hasViewVisible(UserRoles.purchase_order), icon: 'purchase_orders' }, { id: 'sales', title: t`Sales`, link: '/sales/', - hidden: !user.hasViewRole(UserRoles.sales_order), + hidden: !user.hasViewVisible(UserRoles.sales_order), icon: 'sales_orders' }, { diff --git a/src/frontend/src/components/nav/SearchDrawer.tsx b/src/frontend/src/components/nav/SearchDrawer.tsx index a06b774a3c..9c23130e46 100644 --- a/src/frontend/src/components/nav/SearchDrawer.tsx +++ b/src/frontend/src/components/nav/SearchDrawer.tsx @@ -306,7 +306,7 @@ export function SearchDrawer({ part_detail: true }, enabled: - user.hasViewRole(UserRoles.build) && + user.hasViewVisible(UserRoles.build) && userSettings.isSet('SEARCH_PREVIEW_SHOW_BUILD_ORDERS') }, { @@ -316,7 +316,7 @@ export function SearchDrawer({ title: t`Suppliers`, parameters: {}, enabled: - user.hasViewRole(UserRoles.purchase_order) && + user.hasViewVisible(UserRoles.purchase_order) && userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES') }, { @@ -326,7 +326,7 @@ export function SearchDrawer({ title: t`Manufacturers`, parameters: {}, enabled: - user.hasViewRole(UserRoles.purchase_order) && + user.hasViewVisible(UserRoles.purchase_order) && userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES') }, { @@ -336,7 +336,7 @@ export function SearchDrawer({ title: t`Customers`, parameters: {}, enabled: - user.hasViewRole(UserRoles.sales_order) && + user.hasViewVisible(UserRoles.sales_order) && userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES') }, { @@ -350,7 +350,7 @@ export function SearchDrawer({ : undefined }, enabled: - user.hasViewRole(UserRoles.purchase_order) && + user.hasViewVisible(UserRoles.purchase_order) && userSettings.isSet('SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS') }, { @@ -364,14 +364,14 @@ export function SearchDrawer({ : undefined }, enabled: - user.hasViewRole(UserRoles.sales_order) && + user.hasViewVisible(UserRoles.sales_order) && userSettings.isSet('SEARCH_PREVIEW_SHOW_SALES_ORDERS') }, { model: ModelType.salesordershipment, parameters: {}, enabled: - user.hasViewRole(UserRoles.sales_order) && + user.hasViewVisible(UserRoles.sales_order) && userSettings.isSet('SEARCH_PREVIEW_SHOW_SALES_ORDER_SHIPMENTS') }, { @@ -385,7 +385,7 @@ export function SearchDrawer({ : undefined }, enabled: - user.hasViewRole(UserRoles.return_order) && + user.hasViewVisible(UserRoles.return_order) && userSettings.isSet('SEARCH_PREVIEW_SHOW_RETURN_ORDERS') } ]; diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx index d188eff8b2..e69942e386 100644 --- a/src/frontend/src/components/settings/SettingItem.tsx +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -10,14 +10,14 @@ import { Tooltip, useMantineColorScheme } from '@mantine/core'; -import { IconEdit } from '@tabler/icons-react'; +import { IconEdit, IconInfoCircle } from '@tabler/icons-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Boundary } from '@lib/components/Boundary'; import { ModelInformationDict } from '@lib/enums/ModelInformation'; import { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; -import type { Setting } from '@lib/types/Settings'; +import { type Setting, SettingFlag } from '@lib/types/Settings'; import { api } from '../../App'; import { vars } from '../../theme'; import { RenderInstance } from '../render/Instance'; @@ -205,6 +205,23 @@ export function SettingItem({ colorScheme === 'light' ? vars.colors.gray[1] : vars.colors.gray[9]; } + const [flagText, flagColor] = useMemo(() => { + if (!setting.flags || setting.flags.length === 0) { + return ['', '']; + } + + if (setting.flags.includes(SettingFlag.SECURITY)) { + return [t`Security relevant setting`, vars.colors.red[7]]; + } + if (setting.flags.includes(SettingFlag.TOGGLE)) { + return [ + t`Function Toggle - effects system behavior and/or feature visibility`, + vars.colors.blue[7] + ]; + } + return ['', '']; + }, [setting.flags]); + return ( @@ -222,6 +239,11 @@ export function SettingItem({ )} + {flagText && ( + + + + )} }); - user?.hasViewRole(UserRoles.sales_order) && + user?.hasViewVisible(UserRoles.sales_order) && _actions.push({ id: 'sales-orders', label: t`Sales Orders`, @@ -147,8 +147,7 @@ export function getActions(navigate: NavigateFunction) { leftSection: }); - globalSettings.isSet('TRANSFERORDER_ENABLED') && - user?.hasViewRole(UserRoles.transfer_order) && + user?.hasViewVisible(UserRoles.transfer_order) && _actions.push({ id: 'transfer-orders', label: t`Transfer Orders`, @@ -158,8 +157,7 @@ export function getActions(navigate: NavigateFunction) { leftSection: }); - globalSettings.isSet('RETURNORDER_ENABLED') && - user?.hasViewRole(UserRoles.return_order) && + user?.hasViewVisible(UserRoles.return_order) && _actions.push({ id: 'return-orders', label: t`Return Orders`, @@ -178,7 +176,7 @@ export function getActions(navigate: NavigateFunction) { leftSection: }); - user?.hasViewRole(UserRoles.build) && + user?.hasViewVisible(UserRoles.build) && _actions.push({ id: 'builds', label: t`Build Orders`, diff --git a/src/frontend/src/defaults/links.tsx b/src/frontend/src/defaults/links.tsx index c157102d5e..4b3615637c 100644 --- a/src/frontend/src/defaults/links.tsx +++ b/src/frontend/src/defaults/links.tsx @@ -49,29 +49,27 @@ export function getNavTabs(user: UserStateProps): NavTab[] { visible: user.hasViewRole(UserRoles.stock) || user.hasViewRole(UserRoles.stock_location) || - (globalSettings.isSet('TRANSFERORDER_ENABLED') && - user.hasViewRole(UserRoles.transfer_order)) + user.hasViewVisible(UserRoles.transfer_order) }, { name: 'manufacturing', title: t`Manufacturing`, icon: , - visible: user.hasViewRole(UserRoles.build) + visible: user.hasViewVisible(UserRoles.build) }, { name: 'purchasing', title: t`Purchasing`, icon: , - visible: user.hasViewRole(UserRoles.purchase_order) + visible: user.hasViewVisible(UserRoles.purchase_order) }, { name: 'sales', title: t`Sales`, icon: , visible: - user.hasViewRole(UserRoles.sales_order) || - (globalSettings.isSet('RETURNORDER_ENABLED') && - user.hasViewRole(UserRoles.return_order)) + user.hasViewVisible(UserRoles.sales_order) || + user.hasViewVisible(UserRoles.return_order) } ]; diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 36a21214c5..ad74003707 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -14,7 +14,6 @@ import { IconQrcode, IconServerCog, IconShoppingCart, - IconTransfer, IconTruckDelivery } from '@tabler/icons-react'; import { useMemo } from 'react'; @@ -303,6 +302,15 @@ export default function SystemSettings() { 'STOCK_TRACKING_DELETE_DAYS' ]} /> + ) }, @@ -315,6 +323,7 @@ export default function SystemSettings() { ) }, - { - name: 'transferorders', - label: t`Transfer Orders`, - icon: , - content: ( - - ) - }, { name: 'plugins', label: t`Plugins`, diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 9358562117..7a69f25f38 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -335,7 +335,7 @@ export default function BuildDetail() { ), hidden: - !user.hasViewRole(UserRoles.purchase_order) || + !user.hasViewVisible(UserRoles.purchase_order) || !build.external || !globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS') }, diff --git a/src/frontend/src/pages/build/BuildIndex.tsx b/src/frontend/src/pages/build/BuildIndex.tsx index 4b1f7a2a70..4d68b7406b 100644 --- a/src/frontend/src/pages/build/BuildIndex.tsx +++ b/src/frontend/src/pages/build/BuildIndex.tsx @@ -112,7 +112,7 @@ export default function BuildIndex() { ]; }, [user, buildOrderView]); - if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.build)) { + if (!user.isLoggedIn() || !user.hasViewVisible(UserRoles.build)) { return ; } diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index 21c50c9514..94b94890d4 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -108,14 +108,18 @@ export default function CompanyDetail(props: Readonly) { name: 'supplied-parts', label: t`Supplied Parts`, icon: , - hidden: !company?.is_supplier, + hidden: + !company?.is_supplier || + !user?.hasViewVisible(UserRoles.purchase_order), content: company?.pk && }, { name: 'manufactured-parts', label: t`Manufactured Parts`, icon: , - hidden: !company?.is_manufacturer, + hidden: + !company?.is_manufacturer || + !user?.hasViewVisible(UserRoles.purchase_order), content: company?.pk && ( ) @@ -124,7 +128,9 @@ export default function CompanyDetail(props: Readonly) { name: 'purchase-orders', label: t`Purchase Orders`, icon: , - hidden: !company?.is_supplier, + hidden: + !company?.is_supplier || + !user?.hasViewVisible(UserRoles.purchase_order), content: company?.pk && }, { @@ -144,14 +150,17 @@ export default function CompanyDetail(props: Readonly) { name: 'sales-orders', label: t`Sales Orders`, icon: , - hidden: !company?.is_customer, + hidden: + !company?.is_customer || !user?.hasViewVisible(UserRoles.sales_order), content: company?.pk && }, { name: 'return-orders', label: t`Return Orders`, icon: , - hidden: !company?.is_customer, + hidden: + !company?.is_customer || + !user?.hasViewVisible(UserRoles.return_order), content: company.pk ? ( ) : ( diff --git a/src/frontend/src/pages/part/PartAllocationPanel.tsx b/src/frontend/src/pages/part/PartAllocationPanel.tsx index 2574fa9f4e..1a298b6ce4 100644 --- a/src/frontend/src/pages/part/PartAllocationPanel.tsx +++ b/src/frontend/src/pages/part/PartAllocationPanel.tsx @@ -15,7 +15,7 @@ export default function PartAllocationPanel({ part }: Readonly<{ part: any }>) { multiple={true} defaultValue={['buildallocations', 'salesallocations']} > - {part.component && user.hasViewRole(UserRoles.build) && ( + {part.component && user.hasViewVisible(UserRoles.build) && ( {t`Build Order Allocations`} @@ -25,7 +25,7 @@ export default function PartAllocationPanel({ part }: Readonly<{ part: any }>) { )} - {part.salable && user.hasViewRole(UserRoles.sales_order) && ( + {part.salable && user.hasViewVisible(UserRoles.sales_order) && ( {t`Sales Order Allocations`} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 8e0d1345b6..1cc6c26cf9 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -379,7 +379,7 @@ export default function PartDetail() { label: t`Suppliers`, icon: , hidden: - !part.purchaseable || !user.hasViewRole(UserRoles.purchase_order), + !part.purchaseable || !user.hasViewVisible(UserRoles.purchase_order), content: part.pk ? ( @@ -392,7 +392,7 @@ export default function PartDetail() { label: t`Purchase Orders`, icon: , hidden: - !part.purchaseable || !user.hasViewRole(UserRoles.purchase_order), + !part.purchaseable || !user.hasViewVisible(UserRoles.purchase_order), content: part.pk ? ( ) : ( @@ -403,7 +403,7 @@ export default function PartDetail() { name: 'sales_orders', label: t`Sales Orders`, icon: , - hidden: !part.salable || !user.hasViewRole(UserRoles.sales_order), + hidden: !part.salable || !user.hasViewVisible(UserRoles.sales_order), content: part.pk ? ( ) : ( @@ -414,27 +414,21 @@ export default function PartDetail() { name: 'return_orders', label: t`Return Orders`, icon: , - hidden: - !part.salable || - !user.hasViewRole(UserRoles.return_order) || - !globalSettings.isSet('RETURNORDER_ENABLED'), + hidden: !part.salable || !user.hasViewVisible(UserRoles.return_order), content: part.pk ? : }, { name: 'builds', label: t`Build Orders`, icon: , - hidden: !part.assembly || !user.hasViewRole(UserRoles.build), + hidden: !part.assembly || !user.hasViewVisible(UserRoles.build), content: part.pk ? : }, { name: 'transfer_orders', label: t`Transfer Orders`, icon: , - hidden: - part.virtual || - !globalSettings.isSet('TRANSFERORDER_ENABLED') || - !user.hasViewRole(UserRoles.transfer_order), + hidden: part.virtual || !user.hasViewVisible(UserRoles.transfer_order), content: part.pk ? ( ) : ( diff --git a/src/frontend/src/pages/part/PartPricingPanel.tsx b/src/frontend/src/pages/part/PartPricingPanel.tsx index 8289d85662..63c5fc23a5 100644 --- a/src/frontend/src/pages/part/PartPricingPanel.tsx +++ b/src/frontend/src/pages/part/PartPricingPanel.tsx @@ -46,11 +46,11 @@ export default function PartPricingPanel({ part }: Readonly<{ part: any }>) { }, [globalSettings]); const purchaseOrderPricing = useMemo(() => { - return user.hasViewRole(UserRoles.purchase_order) && part?.purchaseable; + return user.hasViewVisible(UserRoles.purchase_order) && part?.purchaseable; }, [user, part]); const salesOrderPricing = useMemo(() => { - return user.hasViewRole(UserRoles.sales_order) && part?.salable; + return user.hasViewVisible(UserRoles.sales_order) && part?.salable; }, [user, part]); const [value, setValue] = useState([panelOptions.overview]); diff --git a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx index 8e8887144e..195ded7d6c 100644 --- a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx +++ b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx @@ -94,7 +94,7 @@ export default function PurchasingIndex() { name: 'purchaseorders', label: t`Purchase Orders`, icon: , - hidden: !user.hasViewRole(UserRoles.purchase_order), + hidden: !user.hasViewVisible(UserRoles.purchase_order), selection: purchaseOrderView, onChange: setPurchaseOrderView, options: [ @@ -228,7 +228,7 @@ export default function PurchasingIndex() { supplierView ]); - if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.purchase_order)) { + if (!user.isLoggedIn() || !user.hasViewVisible(UserRoles.purchase_order)) { return ; } diff --git a/src/frontend/src/pages/sales/SalesIndex.tsx b/src/frontend/src/pages/sales/SalesIndex.tsx index 447229a747..82e5887bc7 100644 --- a/src/frontend/src/pages/sales/SalesIndex.tsx +++ b/src/frontend/src/pages/sales/SalesIndex.tsx @@ -22,6 +22,7 @@ import PermissionDenied from '../../components/errors/PermissionDenied'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup } from '../../components/panels/PanelGroup'; import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel'; +import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; import { CompanyTable } from '../../tables/company/CompanyTable'; import ParametricCompanyTable from '../../tables/company/ParametricCompanyTable'; @@ -84,6 +85,7 @@ const ReturnOrderCalendar = () => { export default function SalesIndex() { const user = useUserState(); + const globalSettings = useGlobalSettingsState(); const [customersView, setCustomersView] = useLocalStorage({ key: 'customer-view', @@ -106,7 +108,7 @@ export default function SalesIndex() { name: 'salesorders', label: t`Sales Orders`, icon: , - hidden: !user.hasViewRole(UserRoles.sales_order), + hidden: !user.hasViewVisible(UserRoles.sales_order), selection: salesOrderView, onChange: setSalesOrderView, options: [ @@ -134,6 +136,7 @@ export default function SalesIndex() { name: 'shipments', label: t`Pending Shipments`, icon: , + hidden: !user.hasViewVisible(UserRoles.sales_order), content: ( , - hidden: !user.hasViewRole(UserRoles.return_order), + hidden: !user.hasViewVisible(UserRoles.return_order), selection: returnOrderView, onChange: setReturnOrderView, options: [ diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index ee7bbcce27..6b472b75be 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -220,7 +220,7 @@ export default function SalesOrderDetail() { name: 'build-orders', label: t`Build Orders`, icon: , - hidden: !user.hasViewRole(UserRoles.build), + hidden: !user.hasViewVisible(UserRoles.build), content: order?.pk ? ( ) : ( diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 1342698721..d928ca02db 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -170,9 +170,7 @@ export default function Stock() { name: 'transfer-orders', label: t`Transfer Orders`, icon: , - hidden: - !user.hasViewRole(UserRoles.transfer_order) || - !globalSettings.isSet('TRANSFERORDER_ENABLED'), + hidden: !user.hasViewVisible(UserRoles.transfer_order), selection: transferOrderView, onChange: setTransferOrderView, options: [ diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index a5d22d83b3..7c2bd1a332 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -134,7 +134,7 @@ export default function StockDetail() { const showTransferAllocations: boolean = useMemo(() => { return ( !stockitem?.part_detail?.virtual && - globalSettings.isSet('TRANSFERORDER_ENABLED') + globalSettings.isSet('TRANSFERORDER_ENABLED') // todo check if role is available ); }, [stockitem]); diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index 531af229b2..1a2a19e3d7 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -2,12 +2,17 @@ import { create } from 'zustand'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import type { ModelType } from '@lib/enums/ModelType'; -import { UserPermissions, type UserRoles } from '@lib/enums/Roles'; +import { + UserPermissions, + type UserRoles, + roleToViewSettingMap +} from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; import type { UserProps, UserStateProps } from '@lib/types/User'; import { api, setApiDefaults } from '../App'; import { clearCsrfCookie } from '../functions/auth'; import { useServerApiState } from './ServerApiState'; +import { useGlobalSettingsState } from './SettingsStates'; /** * Global user information state, using Zustand manager @@ -154,6 +159,16 @@ export const useUserState = create((set, get) => ({ hasViewRole: (role: UserRoles) => { return get().checkUserRole(role, UserPermissions.view); }, + hasViewVisible: (role: UserRoles) => { + if (!get().hasViewRole(role)) { + return false; + } + + const viewSetting = roleToViewSettingMap[role]; + return viewSetting + ? useGlobalSettingsState.getState().isSet(viewSetting) + : true; + }, checkUserPermission: (model: ModelType, permission: UserPermissions) => { // Check if the user has the specified permission for the specified model const user: UserProps = get().user as UserProps; diff --git a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx index 13508f0fae..a1a9186f6f 100644 --- a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx +++ b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx @@ -125,7 +125,7 @@ export default function PartBuildAllocationsTable({ title: t`View Build Order`, modelType: ModelType.build, modelId: record.build, - hidden: !user.hasViewRole(UserRoles.build), + hidden: !user.hasViewVisible(UserRoles.build), navigate: navigate }) ]; diff --git a/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx b/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx index 8d49584f4b..3f7d22a19b 100644 --- a/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx +++ b/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx @@ -91,7 +91,7 @@ export default function PartSalesAllocationsTable({ title: t`View Sales Order`, modelType: ModelType.salesorder, modelId: record.order, - hidden: !user.hasViewRole(UserRoles.sales_order), + hidden: !user.hasViewVisible(UserRoles.sales_order), navigate: navigate }) ]; diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index b7ae035177..c35dd7dae5 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -597,7 +597,7 @@ export default function SalesOrderLineItemTable({ modelType: ModelType.part, modelId: record.part, navigate: navigate, - hidden: !user.hasViewRole(UserRoles.part) + hidden: !user.hasViewVisible(UserRoles.part) }) ]; }, diff --git a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx index db6381176e..483838c043 100644 --- a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx @@ -263,7 +263,7 @@ export default function SalesOrderShipmentTable({ hidden: !record.order || !showOrderInfo || - !user.hasViewRole(UserRoles.sales_order), + !user.hasViewVisible(UserRoles.sales_order), navigate: navigate }) ];