Kaynağa Gözat

Merge pull request #16132 from netbox-community/develop

Release v4.0.2
Jeremy Stretch 1 yıl önce
ebeveyn
işleme
cca1b0a897
53 değiştirilmiş dosya ile 1387 ekleme ve 1186 silme
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 0
      .github/workflows/close-stale-issues.yml
  4. 1 1
      base_requirements.txt
  5. 5 2
      docs/configuration/security.md
  6. 8 0
      docs/configuration/system.md
  7. 23 0
      docs/release-notes/version-4.0.md
  8. 0 2
      netbox/dcim/views.py
  9. 2 2
      netbox/extras/api/views.py
  10. 3 3
      netbox/extras/tables/tables.py
  11. 13 1
      netbox/ipam/forms/filtersets.py
  12. 3 0
      netbox/ipam/tests/test_api.py
  13. 0 5
      netbox/ipam/views.py
  14. 2 3
      netbox/netbox/configuration_example.py
  15. 3 0
      netbox/netbox/constants.py
  16. 1 1
      netbox/netbox/graphql/filter_mixins.py
  17. 4 2
      netbox/netbox/plugins/__init__.py
  18. 7 2
      netbox/netbox/preferences.py
  19. 13 2
      netbox/netbox/settings.py
  20. 2 1
      netbox/netbox/tables/tables.py
  21. 3 1
      netbox/netbox/tests/test_plugins.py
  22. 57 5
      netbox/netbox/tests/test_views.py
  23. 1 0
      netbox/netbox/views/generic/object_views.py
  24. 1 1
      netbox/project-static/package.json
  25. 7 7
      netbox/project-static/yarn.lock
  26. 1 1
      netbox/templates/core/configrevision.html
  27. 1 1
      netbox/templates/core/system.html
  28. 11 9
      netbox/templates/htmx/table.html
  29. 2 1
      netbox/templates/inc/paginator.html
  30. 1 1
      netbox/templates/inc/table_htmx.html
  31. 158 157
      netbox/translations/en/LC_MESSAGES/django.po
  32. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  33. 158 156
      netbox/translations/es/LC_MESSAGES/django.po
  34. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  35. 158 156
      netbox/translations/fr/LC_MESSAGES/django.po
  36. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  37. 161 154
      netbox/translations/ja/LC_MESSAGES/django.po
  38. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  39. 158 156
      netbox/translations/pt/LC_MESSAGES/django.po
  40. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  41. 159 151
      netbox/translations/ru/LC_MESSAGES/django.po
  42. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  43. 158 156
      netbox/translations/tr/LC_MESSAGES/django.po
  44. 2 5
      netbox/users/forms/model_forms.py
  45. 2 2
      netbox/users/preferences.py
  46. 24 13
      netbox/utilities/fields.py
  47. 5 5
      netbox/utilities/querysets.py
  48. 1 1
      netbox/utilities/templates/builtins/htmx_table.html
  49. 56 8
      netbox/utilities/testing/api.py
  50. 2 2
      netbox/utilities/testing/views.py
  51. 3 3
      netbox/virtualization/graphql/types.py
  52. 0 1
      netbox/virtualization/views.py
  53. 4 4
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -26,7 +26,7 @@ body:
     attributes:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.0.1
+      placeholder: v4.0.2
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.0.1
+      placeholder: v4.0.2
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 0
.github/workflows/close-stale-issues.yml

@@ -7,6 +7,7 @@ on:
   workflow_dispatch:
   workflow_dispatch:
 
 
 permissions:
 permissions:
+  actions: write
   issues: write
   issues: write
   pull-requests: write
   pull-requests: write
 
 

+ 1 - 1
base_requirements.txt

@@ -131,7 +131,7 @@ social-auth-app-django
 strawberry-graphql
 strawberry-graphql
 
 
 # Strawberry GraphQL Django extension
 # Strawberry GraphQL Django extension
-# https://github.com/strawberry-graphql/strawberry-django/blob/main/CHANGELOG.md
+# https://github.com/strawberry-graphql/strawberry-django/releases
 strawberry-graphql-django
 strawberry-graphql-django
 
 
 # SVG image rendering (used for rack elevations)
 # SVG image rendering (used for rack elevations)

+ 5 - 2
docs/configuration/security.md

@@ -159,9 +159,12 @@ Note that enabling this setting causes NetBox to update a user's session in the
 
 
 ## LOGIN_REQUIRED
 ## LOGIN_REQUIRED
 
 
-Default: False
+Default: True
+
+When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
 
 
-Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes.
+!!! info "Changed in NetBox v4.0.2"
+    Prior to NetBox v4.0.2, this setting was disabled by default.
 
 
 ---
 ---
 
 

+ 8 - 0
docs/configuration/system.md

@@ -198,3 +198,11 @@ If `STORAGE_BACKEND` is not defined, this setting will be ignored.
 Default: UTC
 Default: UTC
 
 
 The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
 The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
+
+---
+
+## TRANSLATION_ENABLED
+
+Default: True
+
+Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.)

+ 23 - 0
docs/release-notes/version-4.0.md

@@ -1,5 +1,28 @@
 # NetBox v4.0
 # NetBox v4.0
 
 
+## v4.0.2 (2024-05-14)
+
+!!! warning "Important"
+    This release includes an important security fix, and is a strongly recommended update for all users. More details will follow.
+
+### Enhancements
+
+* [#15119](https://github.com/netbox-community/netbox/issues/15119) - Add cluster & cluster group UI filter fields for VLAN groups
+* [#16090](https://github.com/netbox-community/netbox/issues/16090) - Include current NetBox version when an unsupported plugin is detected
+* [#16096](https://github.com/netbox-community/netbox/issues/16096) - Introduce the `ENABLE_TRANSLATION` configuration parameter
+* [#16107](https://github.com/netbox-community/netbox/issues/16107) - Change the default value for `LOGIN_REQUIRED` to True
+* [#16127](https://github.com/netbox-community/netbox/issues/16127) - Add integration point for unsupported settings
+
+### Bug Fixes
+
+* [#16077](https://github.com/netbox-community/netbox/issues/16077) - Fix display of parameter values when viewing configuration revisions
+* [#16078](https://github.com/netbox-community/netbox/issues/16078) - Fix integer filters mistakenly marked as required for GraphQL API
+* [#16101](https://github.com/netbox-community/netbox/issues/16101) - Fix initial loading of pagination widget for dynamic object tables
+* [#16123](https://github.com/netbox-community/netbox/issues/16123) - Fix custom script execution via REST API
+* [#16124](https://github.com/netbox-community/netbox/issues/16124) - Fix GraphQL API support for querying virtual machine interfaces
+
+---
+
 ## v4.0.1 (2024-05-09)
 ## v4.0.1 (2024-05-09)
 
 
 ### Enhancements
 ### Enhancements

+ 0 - 2
netbox/dcim/views.py

@@ -2093,7 +2093,6 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
     child_model = VirtualMachine
     child_model = VirtualMachine
     table = VirtualMachineTable
     table = VirtualMachineTable
     filterset = VirtualMachineFilterSet
     filterset = VirtualMachineFilterSet
-    template_name = 'generic/object_children.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Virtual Machines'),
         label=_('Virtual Machines'),
         badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
         badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@@ -2986,7 +2985,6 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
     child_model = InventoryItem
     child_model = InventoryItem
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     filterset = filtersets.InventoryItemFilterSet
     filterset = filtersets.InventoryItemFilterSet
-    template_name = 'generic/object_children.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Children'),
         label=_('Children'),
         badge=lambda obj: obj.child_items.count(),
         badge=lambda obj: obj.child_items.count(),

+ 2 - 2
netbox/extras/api/views.py

@@ -240,9 +240,9 @@ class ScriptViewSet(ModelViewSet):
             raise RQWorkerNotRunningException()
             raise RQWorkerNotRunningException()
 
 
         if input_serializer.is_valid():
         if input_serializer.is_valid():
-            script.result = Job.enqueue(
+            Job.enqueue(
                 run_script,
                 run_script,
-                instance=script.module,
+                instance=script,
                 name=script.python_class.class_name,
                 name=script.python_class.class_name,
                 user=request.user,
                 user=request.user,
                 data=input_serializer.data['data'],
                 data=input_serializer.data['data'],

+ 3 - 3
netbox/extras/tables/tables.py

@@ -1,10 +1,10 @@
 import json
 import json
 
 
 import django_tables2 as tables
 import django_tables2 as tables
-from django.conf import settings
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from extras.models import *
 from extras.models import *
+from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.tables import BaseTable, NetBoxTable, columns
 from netbox.tables import BaseTable, NetBoxTable, columns
 from .template_code import *
 from .template_code import *
 
 
@@ -550,7 +550,7 @@ class ScriptResultsTable(BaseTable):
     )
     )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
-        empty_text = _('No results found')
+        empty_text = _(EMPTY_TABLE_TEXT)
         fields = (
         fields = (
             'index', 'time', 'status', 'message',
             'index', 'time', 'status', 'message',
         )
         )
@@ -581,7 +581,7 @@ class ReportResultsTable(BaseTable):
     )
     )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
-        empty_text = _('No results found')
+        empty_text = _(EMPTY_TABLE_TEXT)
         fields = (
         fields = (
             'index', 'method', 'time', 'status', 'object', 'url', 'message',
             'index', 'method', 'time', 'status', 'object', 'url', 'message',
         )
         )

+ 13 - 1
netbox/ipam/forms/filtersets.py

@@ -10,7 +10,7 @@ from tenancy.forms import TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
-from virtualization.models import VirtualMachine
+from virtualization.models import VirtualMachine, ClusterGroup, Cluster
 from vpn.models import L2VPN
 from vpn.models import L2VPN
 
 
 __all__ = (
 __all__ = (
@@ -405,6 +405,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
         FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
+        FieldSet('cluster_group', 'cluster', name=_('Cluster')),
         FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
         FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
     )
     )
     model = VLANGroup
     model = VLANGroup
@@ -445,6 +446,17 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
         max_value=VLAN_VID_MAX,
         max_value=VLAN_VID_MAX,
         label=_('Maximum VID')
         label=_('Maximum VID')
     )
     )
+    cluster = DynamicModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        label=_('Cluster')
+    )
+    cluster_group = DynamicModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        label=_('Cluster group')
+    )
+
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

+ 3 - 0
netbox/ipam/tests/test_api.py

@@ -648,6 +648,9 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
+    graphql_filter = {
+        'address': '192.168.0.1/24',
+    }
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):

+ 0 - 5
netbox/ipam/views.py

@@ -214,7 +214,6 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
     child_model = ASN
     child_model = ASN
     table = tables.ASNTable
     table = tables.ASNTable
     filterset = filtersets.ASNFilterSet
     filterset = filtersets.ASNFilterSet
-    template_name = 'generic/object_children.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('ASNs'),
         label=_('ASNs'),
         badge=lambda x: x.get_child_asns().count(),
         badge=lambda x: x.get_child_asns().count(),
@@ -883,7 +882,6 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
     child_model = IPAddress
     child_model = IPAddress
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
     filterset = filtersets.IPAddressFilterSet
-    template_name = 'generic/object_children.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Related IPs'),
         label=_('Related IPs'),
         badge=lambda x: x.get_related_ips().count(),
         badge=lambda x: x.get_related_ips().count(),
@@ -955,7 +953,6 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
     child_model = VLAN
     child_model = VLAN
     table = tables.VLANTable
     table = tables.VLANTable
     filterset = filtersets.VLANFilterSet
     filterset = filtersets.VLANFilterSet
-    template_name = 'generic/object_children.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('VLANs'),
         label=_('VLANs'),
         badge=lambda x: x.get_child_vlans().count(),
         badge=lambda x: x.get_child_vlans().count(),
@@ -1111,7 +1108,6 @@ class VLANInterfacesView(generic.ObjectChildrenView):
     child_model = Interface
     child_model = Interface
     table = tables.VLANDevicesTable
     table = tables.VLANDevicesTable
     filterset = InterfaceFilterSet
     filterset = InterfaceFilterSet
-    template_name = 'generic/object_children.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Device Interfaces'),
         label=_('Device Interfaces'),
         badge=lambda x: x.get_interfaces().count(),
         badge=lambda x: x.get_interfaces().count(),
@@ -1129,7 +1125,6 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
     child_model = VMInterface
     child_model = VMInterface
     table = tables.VLANVirtualMachinesTable
     table = tables.VLANVirtualMachinesTable
     filterset = VMInterfaceFilterSet
     filterset = VMInterfaceFilterSet
-    template_name = 'generic/object_children.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('VM Interfaces'),
         label=_('VM Interfaces'),
         badge=lambda x: x.get_vminterfaces().count(),
         badge=lambda x: x.get_vminterfaces().count(),

+ 2 - 3
netbox/netbox/configuration_example.py

@@ -157,9 +157,8 @@ LOGGING = {}
 # authenticated to NetBox indefinitely.
 # authenticated to NetBox indefinitely.
 LOGIN_PERSISTENCE = False
 LOGIN_PERSISTENCE = False
 
 
-# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
-# are permitted to access most data in NetBox but not make any changes.
-LOGIN_REQUIRED = False
+# Setting this to False will permit unauthenticated users to access most areas of NetBox (but not make any changes).
+LOGIN_REQUIRED = True
 
 
 # The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to
 # The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to
 # re-authenticate. (Default: 1209600 [14 days])
 # re-authenticate. (Default: 1209600 [14 days])

+ 3 - 0
netbox/netbox/constants.py

@@ -41,3 +41,6 @@ DEFAULT_ACTION_PERMISSIONS = {
 # General-purpose tokens
 # General-purpose tokens
 CENSOR_TOKEN = '********'
 CENSOR_TOKEN = '********'
 CENSOR_TOKEN_CHANGED = '***CHANGED***'
 CENSOR_TOKEN_CHANGED = '***CHANGED***'
+
+# Placeholder text for empty tables
+EMPTY_TABLE_TEXT = 'No results found'

+ 1 - 1
netbox/netbox/graphql/filter_mixins.py

@@ -87,7 +87,7 @@ def map_strawberry_type(field):
         pass
         pass
     elif issubclass(type(field), django_filters.NumberFilter):
     elif issubclass(type(field), django_filters.NumberFilter):
         should_create_function = True
         should_create_function = True
-        attr_type = int
+        attr_type = int | None
     elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter):
     elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter):
         should_create_function = True
         should_create_function = True
         attr_type = List[str] | None
         attr_type = List[str] | None

+ 4 - 2
netbox/netbox/plugins/__init__.py

@@ -138,13 +138,15 @@ class PluginConfig(AppConfig):
             min_version = version.parse(cls.min_version)
             min_version = version.parse(cls.min_version)
             if current_version < min_version:
             if current_version < min_version:
                 raise ImproperlyConfigured(
                 raise ImproperlyConfigured(
-                    f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
+                    f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: "
+                    f"{netbox_version})."
                 )
                 )
         if cls.max_version is not None:
         if cls.max_version is not None:
             max_version = version.parse(cls.max_version)
             max_version = version.parse(cls.max_version)
             if current_version > max_version:
             if current_version > max_version:
                 raise ImproperlyConfigured(
                 raise ImproperlyConfigured(
-                    f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
+                    f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: "
+                    f"{netbox_version})."
                 )
                 )
 
 
         # Verify required configuration settings
         # Verify required configuration settings

+ 7 - 2
netbox/netbox/preferences.py

@@ -23,7 +23,7 @@ PREFERENCES = {
         ),
         ),
         description=_('Enable dynamic UI navigation'),
         description=_('Enable dynamic UI navigation'),
         default=False,
         default=False,
-        experimental=True
+        warning=_('Experimental feature')
     ),
     ),
     'locale.language': UserPreference(
     'locale.language': UserPreference(
         label=_('Language'),
         label=_('Language'),
@@ -31,7 +31,12 @@ PREFERENCES = {
             ('', _('Auto')),
             ('', _('Auto')),
             *settings.LANGUAGES,
             *settings.LANGUAGES,
         ),
         ),
-        description=_('Forces UI translation to the specified language.')
+        description=_('Forces UI translation to the specified language'),
+        warning=(
+            _("Support for translation has been disabled locally")
+            if not settings.TRANSLATION_ENABLED
+            else ''
+        )
     ),
     ),
     'pagination.per_page': UserPreference(
     'pagination.per_page': UserPreference(
         label=_('Page length'),
         label=_('Page length'),

+ 13 - 2
netbox/netbox/settings.py

@@ -25,7 +25,7 @@ from utilities.string import trailing_slash
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '4.0.1'
+VERSION = '4.0.2'
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
 # Set the base directory two levels up
 # Set the base directory two levels up
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -105,7 +105,7 @@ LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us')
 LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH
 LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
 LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
-LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
+LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
 LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
 MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
 MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
@@ -156,6 +156,7 @@ SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
 STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
 STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
 STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
 TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
 TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
+TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
 
 
 # Load any dynamic configuration parameters which have been hard-coded in the configuration file
 # Load any dynamic configuration parameters which have been hard-coded in the configuration file
 for param in CONFIG_PARAMS:
 for param in CONFIG_PARAMS:
@@ -445,6 +446,9 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
 # Use timezone-aware datetime objects
 # Use timezone-aware datetime objects
 USE_TZ = True
 USE_TZ = True
 
 
+# Toggle language translation support
+USE_I18N = TRANSLATION_ENABLED
+
 # WSGI
 # WSGI
 WSGI_APPLICATION = 'netbox.wsgi.application'
 WSGI_APPLICATION = 'netbox.wsgi.application'
 SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
 SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
@@ -801,3 +805,10 @@ for plugin_name in PLUGINS:
     RQ_QUEUES.update({
     RQ_QUEUES.update({
         f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues
         f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues
     })
     })
+
+# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
+try:
+    from .local_settings import *
+    _UNSUPPORTED_SETTINGS = True
+except ImportError:
+    pass

+ 2 - 1
netbox/netbox/tables/tables.py

@@ -14,6 +14,7 @@ from django_tables2.data import TableQuerysetData
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField, CustomLink
 from extras.models import CustomField, CustomLink
+from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.registry import registry
 from netbox.registry import registry
 from netbox.tables import columns
 from netbox.tables import columns
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -258,7 +259,7 @@ class SearchTable(tables.Table):
         attrs = {
         attrs = {
             'class': 'table table-hover object-list',
             'class': 'table table-hover object-list',
         }
         }
-        empty_text = _('No results found')
+        empty_text = _(EMPTY_TABLE_TEXT)
 
 
     def __init__(self, data, highlight=None, **kwargs):
     def __init__(self, data, highlight=None, **kwargs):
         self.highlight = highlight
         self.highlight = highlight

+ 3 - 1
netbox/netbox/tests/test_plugins.py

@@ -42,6 +42,7 @@ class PluginTest(TestCase):
         url = reverse('admin:dummy_plugin_dummymodel_add')
         url = reverse('admin:dummy_plugin_dummymodel_add')
         self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/')
         self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/')
 
 
+    @override_settings(LOGIN_REQUIRED=False)
     def test_views(self):
     def test_views(self):
 
 
         # Test URL resolution
         # Test URL resolution
@@ -53,7 +54,7 @@ class PluginTest(TestCase):
         response = client.get(url)
         response = client.get(url)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
     def test_api_views(self):
     def test_api_views(self):
 
 
         # Test URL resolution
         # Test URL resolution
@@ -65,6 +66,7 @@ class PluginTest(TestCase):
         response = client.get(url)
         response = client.get(url)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @override_settings(LOGIN_REQUIRED=False)
     def test_registered_views(self):
     def test_registered_views(self):
 
 
         # Test URL resolution
         # Test URL resolution

+ 57 - 5
netbox/netbox/tests/test_views.py

@@ -1,24 +1,76 @@
 import urllib.parse
 import urllib.parse
 
 
-from utilities.testing import TestCase
 from django.urls import reverse
 from django.urls import reverse
+from django.test import override_settings
+
+from dcim.models import Site
+from netbox.constants import EMPTY_TABLE_TEXT
+from netbox.search.backends import search_backend
+from utilities.testing import TestCase
 
 
 
 
 class HomeViewTestCase(TestCase):
 class HomeViewTestCase(TestCase):
 
 
     def test_home(self):
     def test_home(self):
-
         url = reverse('home')
         url = reverse('home')
-
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
 
 
+
+class SearchViewTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        sites = (
+            Site(name='Site Alpha', slug='alpha', description='Red'),
+            Site(name='Site Bravo', slug='bravo', description='Red'),
+            Site(name='Site Charlie', slug='charlie', description='Green'),
+            Site(name='Site Delta', slug='delta', description='Green'),
+            Site(name='Site Echo', slug='echo', description='Blue'),
+            Site(name='Site Foxtrot', slug='foxtrot', description='Blue'),
+        )
+        Site.objects.bulk_create(sites)
+        search_backend.cache(sites)
+
     def test_search(self):
     def test_search(self):
+        url = reverse('search')
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+
+    def test_search_query(self):
+        url = reverse('search')
+        params = {
+            'q': 'red',
+        }
+        query = urllib.parse.urlencode(params)
+
+        # Test without view permission
+        response = self.client.get(f'{url}?{query}')
+        self.assertHttpStatus(response, 200)
+        content = str(response.content)
+        self.assertIn(EMPTY_TABLE_TEXT, content)
+
+        # Add view permissions & query again. Only matching objects should be listed
+        self.add_permissions('dcim.view_site')
+        response = self.client.get(f'{url}?{query}')
+        self.assertHttpStatus(response, 200)
+        content = str(response.content)
+        self.assertIn('Site Alpha', content)
+        self.assertIn('Site Bravo', content)
+        self.assertNotIn('Site Charlie', content)
+        self.assertNotIn('Site Delta', content)
+        self.assertNotIn('Site Echo', content)
+        self.assertNotIn('Site Foxtrot', content)
 
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_search_no_results(self):
         url = reverse('search')
         url = reverse('search')
         params = {
         params = {
-            'q': 'foo',
+            'q': 'xxxxxxxxx',  # Matches nothing
         }
         }
+        query = urllib.parse.urlencode(params)
 
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        response = self.client.get(f'{url}?{query}')
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
+        content = str(response.content)
+        self.assertIn(EMPTY_TABLE_TEXT, content)

+ 1 - 0
netbox/netbox/views/generic/object_views.py

@@ -93,6 +93,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
     child_model = None
     child_model = None
     table = None
     table = None
     filterset = None
     filterset = None
+    template_name = 'generic/object_children.html'
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         """
         """

+ 1 - 1
netbox/project-static/package.json

@@ -30,7 +30,7 @@
     "gridstack": "10.1.2",
     "gridstack": "10.1.2",
     "htmx.org": "1.9.12",
     "htmx.org": "1.9.12",
     "query-string": "9.0.0",
     "query-string": "9.0.0",
-    "sass": "1.77.0",
+    "sass": "1.77.1",
     "tom-select": "2.3.1",
     "tom-select": "2.3.1",
     "typeface-inter": "3.18.1",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
     "typeface-roboto-mono": "1.1.13"

+ 7 - 7
netbox/project-static/yarn.lock

@@ -1816,9 +1816,9 @@ ignore@^5.2.0:
   integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
   integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
 
 
 immutable@^4.0.0:
 immutable@^4.0.0:
-  version "4.3.5"
-  resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.5.tgz#f8b436e66d59f99760dc577f5c99a4fd2a5cc5a0"
-  integrity sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==
+  version "4.3.6"
+  resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447"
+  integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==
 
 
 import-fresh@^3.2.1:
 import-fresh@^3.2.1:
   version "3.3.0"
   version "3.3.0"
@@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
     es-errors "^1.3.0"
     es-errors "^1.3.0"
     is-regex "^1.1.4"
     is-regex "^1.1.4"
 
 
-sass@1.77.0:
-  version "1.77.0"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.0.tgz#e736c69aff9fae4a4e6dae60a979eee9c942f321"
-  integrity sha512-eGj4HNfXqBWtSnvItNkn7B6icqH14i3CiCGbzMKs3BAPTq62pp9NBYsBgyN4cA+qssqo9r26lW4JSvlaUUWbgw==
+sass@1.77.1:
+  version "1.77.1"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.1.tgz#018cdfb206afd14724030c02e9fefd8f30a76cd0"
+  integrity sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==
   dependencies:
   dependencies:
     chokidar ">=3.0.0 <4.0.0"
     chokidar ">=3.0.0 <4.0.0"
     immutable "^4.0.0"
     immutable "^4.0.0"

+ 1 - 1
netbox/templates/core/configrevision.html

@@ -33,7 +33,7 @@
     <div class="col col-md-12">
     <div class="col col-md-12">
       <div class="card">
       <div class="card">
         <h5 class="card-header">{% trans "Configuration Data" %}</h5>
         <h5 class="card-header">{% trans "Configuration Data" %}</h5>
-        {% include 'core/inc/config_data.html' with config=config.data %}
+        {% include 'core/inc/config_data.html' with config=object.data %}
       </div>
       </div>
 
 
       <div class="card">
       <div class="card">

+ 1 - 1
netbox/templates/core/system.html

@@ -40,7 +40,7 @@
             <td>{{ stats.django_version }}</td>
             <td>{{ stats.django_version }}</td>
           </tr>
           </tr>
           <tr>
           <tr>
-            <th scope="row">{% trans "PotsgreSQL version" %}</th>
+            <th scope="row">{% trans "PostgreSQL version" %}</th>
             <td>{{ stats.postgresql_version }}</td>
             <td>{{ stats.postgresql_version }}</td>
           </tr>
           </tr>
           <tr>
           <tr>

+ 11 - 9
netbox/templates/htmx/table.html

@@ -2,15 +2,17 @@
 {% load helpers %}
 {% load helpers %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
 
 
-{% with preferences|get_key:"pagination.placement" as paginator_placement %}
-  {% if paginator_placement == 'top' or paginator_placement == 'both' %}
-    {% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page placement='top' %}
-  {% endif %}
-  {% render_table table 'inc/table_htmx.html' %}
-  {% if paginator_placement != 'top' %}
-    {% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %}
-  {% endif %}
-{% endwith %}
+<div class="htmx-container table-responsive">
+  {% with preferences|get_key:"pagination.placement" as paginator_placement %}
+    {% if paginator_placement == 'top' or paginator_placement == 'both' %}
+      {% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page placement='top' %}
+    {% endif %}
+    {% render_table table 'inc/table_htmx.html' %}
+    {% if paginator_placement != 'top' %}
+      {% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %}
+    {% endif %}
+  {% endwith %}
+</div>
 
 
 {# Include the updated object count for display elsewhere on the page #}
 {# Include the updated object count for display elsewhere on the page #}
 <div class="d-none" hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div>
 <div class="d-none" hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div>

+ 2 - 1
netbox/templates/inc/paginator.html

@@ -5,7 +5,8 @@
   <div
   <div
       class="d-flex justify-content-between align-items-center border-{% if placement == 'top' %}bottom{% else %}top{% endif %} p-2"
       class="d-flex justify-content-between align-items-center border-{% if placement == 'top' %}bottom{% else %}top{% endif %} p-2"
       hx-target="closest .htmx-container"
       hx-target="closest .htmx-container"
-      hx-disinherit="hx-select hx-swap"
+      hx-disinherit="hx-select"
+      hx-swap="outerHTML"
       {% if not table.embedded %}hx-push-url="true"{% endif %}
       {% if not table.embedded %}hx-push-url="true"{% endif %}
   >
   >
 
 

+ 1 - 1
netbox/templates/inc/table_htmx.html

@@ -1,5 +1,5 @@
 {% load django_tables2 %}
 {% load django_tables2 %}
-<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %} hx-disinherit="hx-target hx-select hx-swap">
+<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %} hx-disinherit="hx-target hx-select" hx-swap="outerHTML">
   {% if table.show_header %}
   {% if table.show_header %}
     <thead
     <thead
         hx-target="closest .htmx-container"
         hx-target="closest .htmx-container"

Dosya farkı çok büyük olduğundan ihmal edildi
+ 158 - 157
netbox/translations/en/LC_MESSAGES/django.po


BIN
netbox/translations/es/LC_MESSAGES/django.mo


Dosya farkı çok büyük olduğundan ihmal edildi
+ 158 - 156
netbox/translations/es/LC_MESSAGES/django.po


BIN
netbox/translations/fr/LC_MESSAGES/django.mo


Dosya farkı çok büyük olduğundan ihmal edildi
+ 158 - 156
netbox/translations/fr/LC_MESSAGES/django.po


BIN
netbox/translations/ja/LC_MESSAGES/django.mo


Dosya farkı çok büyük olduğundan ihmal edildi
+ 161 - 154
netbox/translations/ja/LC_MESSAGES/django.po


BIN
netbox/translations/pt/LC_MESSAGES/django.mo


Dosya farkı çok büyük olduğundan ihmal edildi
+ 158 - 156
netbox/translations/pt/LC_MESSAGES/django.po


BIN
netbox/translations/ru/LC_MESSAGES/django.mo


Dosya farkı çok büyük olduğundan ihmal edildi
+ 159 - 151
netbox/translations/ru/LC_MESSAGES/django.po


BIN
netbox/translations/tr/LC_MESSAGES/django.mo


Dosya farkı çok büyük olduğundan ihmal edildi
+ 158 - 156
netbox/translations/tr/LC_MESSAGES/django.po


+ 2 - 5
netbox/users/forms/model_forms.py

@@ -40,11 +40,8 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
             help_text = f'<code>{field_name}</code>'
             help_text = f'<code>{field_name}</code>'
             if preference.description:
             if preference.description:
                 help_text = f'{preference.description}<br />{help_text}'
                 help_text = f'{preference.description}<br />{help_text}'
-            if preference.experimental:
-                help_text = (
-                    f'<span class="text-danger"><i class="mdi mdi-alert"></i> Experimental feature</span><br />'
-                    f'{help_text}'
-                )
+            if warning := preference.warning:
+                help_text = f'<span class="text-danger"><i class="mdi mdi-alert"></i> {warning}</span><br />{help_text}'
             field_kwargs = {
             field_kwargs = {
                 'label': preference.label,
                 'label': preference.label,
                 'choices': preference.choices,
                 'choices': preference.choices,

+ 2 - 2
netbox/users/preferences.py

@@ -2,10 +2,10 @@ class UserPreference:
     """
     """
     Represents a configurable user preference.
     Represents a configurable user preference.
     """
     """
-    def __init__(self, label, choices, default=None, description='', coerce=lambda x: x, experimental=False):
+    def __init__(self, label, choices, default=None, description='', coerce=lambda x: x, warning=''):
         self.label = label
         self.label = label
         self.choices = choices
         self.choices = choices
         self.default = default if default is not None else choices[0]
         self.default = default if default is not None else choices[0]
         self.description = description
         self.description = description
         self.coerce = coerce
         self.coerce = coerce
-        self.experimental = experimental
+        self.warning = warning

+ 24 - 13
netbox/utilities/fields.py

@@ -70,14 +70,24 @@ class RestrictedGenericForeignKey(GenericForeignKey):
     #  1. Capture restrict_params from RestrictedPrefetch (hack)
     #  1. Capture restrict_params from RestrictedPrefetch (hack)
     #  2. If restrict_params is set, call restrict() on the queryset for
     #  2. If restrict_params is set, call restrict() on the queryset for
     #     the related model
     #     the related model
-    def get_prefetch_queryset(self, instances, queryset=None):
+    def get_prefetch_querysets(self, instances, querysets=None):
         restrict_params = {}
         restrict_params = {}
+        custom_queryset_dict = {}
 
 
         # Compensate for the hack in RestrictedPrefetch
         # Compensate for the hack in RestrictedPrefetch
-        if type(queryset) is dict:
-            restrict_params = queryset
-        elif queryset is not None:
-            raise ValueError(_("Custom queryset can't be used for this lookup."))
+        if type(querysets) is dict:
+            restrict_params = querysets
+
+        elif querysets is not None:
+            for queryset in querysets:
+                ct_id = self.get_content_type(
+                    model=queryset.query.model, using=queryset.db
+                ).pk
+                if ct_id in custom_queryset_dict:
+                    raise ValueError(
+                        "Only one queryset is allowed for each content type."
+                    )
+                custom_queryset_dict[ct_id] = queryset
 
 
         # For efficiency, group the instances by content type and then do one
         # For efficiency, group the instances by content type and then do one
         # query per model
         # query per model
@@ -100,15 +110,16 @@ class RestrictedGenericForeignKey(GenericForeignKey):
 
 
         ret_val = []
         ret_val = []
         for ct_id, fkeys in fk_dict.items():
         for ct_id, fkeys in fk_dict.items():
-            instance = instance_dict[ct_id]
-            ct = self.get_content_type(id=ct_id, using=instance._state.db)
-            if restrict_params:
-                # Override the default behavior to call restrict() on each model's queryset
-                qs = ct.model_class().objects.filter(pk__in=fkeys).restrict(**restrict_params)
-                ret_val.extend(qs)
+            if ct_id in custom_queryset_dict:
+                # Return values from the custom queryset, if provided.
+                ret_val.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys))
             else:
             else:
-                # Default behavior
-                ret_val.extend(ct.get_all_objects_for_this_type(pk__in=fkeys))
+                instance = instance_dict[ct_id]
+                ct = self.get_content_type(id=ct_id, using=instance._state.db)
+                qs = ct.model_class().objects.filter(pk__in=fkeys)
+                if restrict_params:
+                    qs = qs.restrict(**restrict_params)
+                ret_val.extend(qs)
 
 
         # For doing the join in Python, we have to match both the FK val and the
         # For doing the join in Python, we have to match both the FK val and the
         # content type, so we use a callable that returns a (fk, class) pair.
         # content type, so we use a callable that returns a (fk, class) pair.

+ 5 - 5
netbox/utilities/querysets.py

@@ -20,14 +20,14 @@ class RestrictedPrefetch(Prefetch):
 
 
         super().__init__(lookup, queryset=queryset, to_attr=to_attr)
         super().__init__(lookup, queryset=queryset, to_attr=to_attr)
 
 
-    def get_current_queryset(self, level):
+    def get_current_querysets(self, level):
         params = {
         params = {
             'user': self.restrict_user,
             'user': self.restrict_user,
             'action': self.restrict_action,
             'action': self.restrict_action,
         }
         }
 
 
-        if qs := super().get_current_queryset(level):
-            return qs.restrict(**params)
+        if querysets := super().get_current_querysets(level):
+            return [qs.restrict(**params) for qs in querysets]
 
 
         # Bit of a hack. If no queryset is defined, pass through the dict of restrict()
         # Bit of a hack. If no queryset is defined, pass through the dict of restrict()
         # kwargs to be handled by the field. This is necessary e.g. for GenericForeignKey
         # kwargs to be handled by the field. This is necessary e.g. for GenericForeignKey
@@ -49,11 +49,11 @@ class RestrictedQuerySet(QuerySet):
         permission_required = get_permission_for_model(self.model, action)
         permission_required = get_permission_for_model(self.model, action)
 
 
         # Bypass restriction for superusers and exempt views
         # Bypass restriction for superusers and exempt views
-        if user.is_superuser or permission_is_exempt(permission_required):
+        if user and user.is_superuser or permission_is_exempt(permission_required):
             qs = self
             qs = self
 
 
         # User is anonymous or has not been granted the requisite permission
         # User is anonymous or has not been granted the requisite permission
-        elif not user.is_authenticated or permission_required not in user.get_all_permissions():
+        elif user is None or not user.is_authenticated or permission_required not in user.get_all_permissions():
             qs = self.none()
             qs = self.none()
 
 
         # Filter the queryset to include only objects with allowed attributes
         # Filter the queryset to include only objects with allowed attributes

+ 1 - 1
netbox/utilities/templates/builtins/htmx_table.html

@@ -1,5 +1,5 @@
 <div class="htmx-container table-responsive"
 <div class="htmx-container table-responsive"
   hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}"
   hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}"
   hx-target="this"
   hx-target="this"
-  hx-trigger="load" hx-select="table" hx-swap="innerHTML"
+  hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
 ></div>
 ></div>

+ 56 - 8
netbox/utilities/testing/api.py

@@ -73,7 +73,7 @@ class APIViewTestCases:
 
 
     class GetObjectViewTestCase(APITestCase):
     class GetObjectViewTestCase(APITestCase):
 
 
-        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
         def test_get_object_anonymous(self):
         def test_get_object_anonymous(self):
             """
             """
             GET a single object as an unauthenticated user.
             GET a single object as an unauthenticated user.
@@ -135,7 +135,7 @@ class APIViewTestCases:
     class ListObjectsViewTestCase(APITestCase):
     class ListObjectsViewTestCase(APITestCase):
         brief_fields = []
         brief_fields = []
 
 
-        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
         def test_list_objects_anonymous(self):
         def test_list_objects_anonymous(self):
             """
             """
             GET a list of objects as an unauthenticated user.
             GET a list of objects as an unauthenticated user.
@@ -440,13 +440,12 @@ class APIViewTestCases:
             base_name = self.model._meta.verbose_name.lower().replace(' ', '_')
             base_name = self.model._meta.verbose_name.lower().replace(' ', '_')
             return getattr(self, 'graphql_base_name', base_name)
             return getattr(self, 'graphql_base_name', base_name)
 
 
-        def _build_query(self, name, **filters):
+        def _build_query_with_filter(self, name, filter_string):
+            """
+            Called by either _build_query or _build_filtered_query - construct the actual
+            query given a name and filter string
+            """
             type_class = get_graphql_type_for_model(self.model)
             type_class = get_graphql_type_for_model(self.model)
-            if filters:
-                filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
-                filter_string = f'({filter_string})'
-            else:
-                filter_string = ''
 
 
             # Compile list of fields to include
             # Compile list of fields to include
             fields_string = ''
             fields_string = ''
@@ -492,6 +491,30 @@ class APIViewTestCases:
 
 
             return query
             return query
 
 
+        def _build_filtered_query(self, name, **filters):
+            """
+            Create a filtered query: i.e. ip_address_list(filters: {address: "1.1.1.1/24"}){.
+            """
+            if filters:
+                filter_string = ', '.join(f'{k}: "{v}"' for k, v in filters.items())
+                filter_string = f'(filters: {{{filter_string}}})'
+            else:
+                filter_string = ''
+
+            return self._build_query_with_filter(name, filter_string)
+
+        def _build_query(self, name, **filters):
+            """
+            Create a normal query - unfiltered or with a string query: i.e. site(name: "aaa"){.
+            """
+            if filters:
+                filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
+                filter_string = f'({filter_string})'
+            else:
+                filter_string = ''
+
+            return self._build_query_with_filter(name, filter_string)
+
         @override_settings(LOGIN_REQUIRED=True)
         @override_settings(LOGIN_REQUIRED=True)
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
         def test_graphql_get_object(self):
         def test_graphql_get_object(self):
@@ -550,6 +573,31 @@ class APIViewTestCases:
             self.assertNotIn('errors', data)
             self.assertNotIn('errors', data)
             self.assertGreater(len(data['data'][field_name]), 0)
             self.assertGreater(len(data['data'][field_name]), 0)
 
 
+        @override_settings(LOGIN_REQUIRED=True)
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
+        def test_graphql_filter_objects(self):
+            if not hasattr(self, 'graphql_filter'):
+                return
+
+            url = reverse('graphql')
+            field_name = f'{self._get_graphql_base_name()}_list'
+            query = self._build_filtered_query(field_name, **self.graphql_filter)
+
+            # Add object-level permission
+            obj_perm = ObjectPermission(
+                name='Test permission',
+                actions=['view']
+            )
+            obj_perm.save()
+            obj_perm.users.add(self.user)
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+            response = self.client.post(url, data={'query': query}, format="json", **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            data = json.loads(response.content)
+            self.assertNotIn('errors', data)
+            self.assertGreater(len(data['data'][field_name]), 0)
+
     class APIViewTestCase(
     class APIViewTestCase(
         GetObjectViewTestCase,
         GetObjectViewTestCase,
         ListObjectsViewTestCase,
         ListObjectsViewTestCase,

+ 2 - 2
netbox/utilities/testing/views.py

@@ -62,7 +62,7 @@ class ViewTestCases:
         """
         """
         Retrieve a single instance.
         Retrieve a single instance.
         """
         """
-        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
         def test_get_object_anonymous(self):
         def test_get_object_anonymous(self):
             # Make the request as an unauthenticated user
             # Make the request as an unauthenticated user
             self.client.logout()
             self.client.logout()
@@ -421,7 +421,7 @@ class ViewTestCases:
         """
         """
         Retrieve multiple instances.
         Retrieve multiple instances.
         """
         """
-        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
         def test_list_objects_anonymous(self):
         def test_list_objects_anonymous(self):
             # Make the request as an unauthenticated user
             # Make the request as an unauthenticated user
             self.client.logout()
             self.client.logout()

+ 3 - 3
netbox/virtualization/graphql/types.py

@@ -84,7 +84,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
     primary_ip4: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
     primary_ip4: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
     primary_ip6: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
     primary_ip6: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
 
 
-    interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
+    interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
     services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]]
     services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]]
     virtualdisks: List[Annotated["VirtualDiskType", strawberry.lazy('virtualization.graphql.types')]]
     virtualdisks: List[Annotated["VirtualDiskType", strawberry.lazy('virtualization.graphql.types')]]
 
 
@@ -102,8 +102,8 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
 
 
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
-    bridge_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
-    child_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
+    bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
+    child_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(

+ 0 - 1
netbox/virtualization/views.py

@@ -181,7 +181,6 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
     child_model = VirtualMachine
     child_model = VirtualMachine
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
     filterset = filtersets.VirtualMachineFilterSet
     filterset = filtersets.VirtualMachineFilterSet
-    template_name = 'generic/object_children.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Virtual Machines'),
         label=_('Virtual Machines'),
         badge=lambda obj: obj.virtual_machines.count(),
         badge=lambda obj: obj.virtual_machines.count(),

+ 4 - 4
requirements.txt

@@ -20,18 +20,18 @@ feedparser==6.0.11
 gunicorn==22.0.0
 gunicorn==22.0.0
 Jinja2==3.1.4
 Jinja2==3.1.4
 Markdown==3.6
 Markdown==3.6
-mkdocs-material==9.5.21
+mkdocs-material==9.5.22
 mkdocstrings[python-legacy]==0.25.1
 mkdocstrings[python-legacy]==0.25.1
 netaddr==1.2.1
 netaddr==1.2.1
 nh3==0.2.17
 nh3==0.2.17
 Pillow==10.3.0
 Pillow==10.3.0
-psycopg[c,pool]==3.1.18
+psycopg[c,pool]==3.1.19
 PyYAML==6.0.1
 PyYAML==6.0.1
 requests==2.31.0
 requests==2.31.0
 social-auth-app-django==5.4.1
 social-auth-app-django==5.4.1
 social-auth-core==4.5.4
 social-auth-core==4.5.4
-strawberry-graphql==0.227.4
-strawberry-graphql-django==0.39.2
+strawberry-graphql==0.229.0
+strawberry-graphql-django==0.40.0
 svgwrite==1.4.3
 svgwrite==1.4.3
 tablib==3.6.1
 tablib==3.6.1
 tzdata==2024.1
 tzdata==2024.1

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor