2
0
Эх сурвалжийг харах

Merge branch 'develop' into feature

Jeremy Stretch 2 жил өмнө
parent
commit
2a4e3dd09f
42 өөрчлөгдсөн 408 нэмэгдсэн , 873 устгасан
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 0
      contrib/generated_schema.json
  4. 1 1
      docs/customization/reports.md
  5. 1 1
      docs/installation/upgrading.md
  6. 20 19
      docs/reference/filtering.md
  7. 27 1
      docs/release-notes/version-3.5.md
  8. 7 6
      netbox/core/data_backends.py
  9. 1 0
      netbox/dcim/api/serializers.py
  10. 2 1
      netbox/dcim/forms/model_forms.py
  11. 0 33
      netbox/dcim/views.py
  12. 4 4
      netbox/extras/forms/filtersets.py
  13. 1 4
      netbox/extras/management/commands/reindex.py
  14. 1 0
      netbox/extras/plugins/__init__.py
  15. 5 5
      netbox/extras/scripts.py
  16. 31 6
      netbox/extras/tests/test_filtersets.py
  17. 15 0
      netbox/extras/views.py
  18. 16 0
      netbox/ipam/filtersets.py
  19. 4 2
      netbox/ipam/forms/bulk_import.py
  20. 6 0
      netbox/ipam/models/vlans.py
  21. 6 1
      netbox/ipam/tests/test_filtersets.py
  22. 24 13
      netbox/ipam/views.py
  23. 8 4
      netbox/netbox/filtersets.py
  24. 19 10
      netbox/netbox/tables/columns.py
  25. 1 1
      netbox/netbox/views/generic/bulk_views.py
  26. 0 0
      netbox/project-static/dist/graphiql.css
  27. 0 0
      netbox/project-static/dist/graphiql.js
  28. 0 0
      netbox/project-static/dist/graphiql.js.map
  29. 1 1
      netbox/project-static/netbox-graphiql/package.json
  30. 77 608
      netbox/project-static/yarn.lock
  31. 9 1
      netbox/templates/dcim/device/components_base.html
  32. 0 83
      netbox/templates/dcim/inc/nonracked_devices.html
  33. 21 1
      netbox/templates/dcim/location.html
  34. 28 44
      netbox/templates/dcim/site.html
  35. 18 0
      netbox/templates/extras/customfield.html
  36. 0 11
      netbox/templates/ipam/vlangroup.html
  37. 7 1
      netbox/users/api/views.py
  38. 6 1
      netbox/utilities/constants.py
  39. 11 6
      netbox/utilities/fields.py
  40. 2 1
      netbox/utilities/templates/helpers/utilization_graph.html
  41. 25 0
      netbox/utilities/tests/test_filters.py
  42. 0 1
      netbox/virtualization/views.py

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.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: v3.5.8
+      placeholder: v3.5.9
     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: v3.5.8
+      placeholder: v3.5.9
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 0
contrib/generated_schema.json

@@ -332,6 +332,7 @@
                         "100gbase-x-cfp",
                         "100gbase-x-cfp",
                         "100gbase-x-cfp2",
                         "100gbase-x-cfp2",
                         "200gbase-x-cfp2",
                         "200gbase-x-cfp2",
+                        "400gbase-x-cfp2",
                         "100gbase-x-cfp4",
                         "100gbase-x-cfp4",
                         "100gbase-x-cxp",
                         "100gbase-x-cxp",
                         "100gbase-x-cpak",
                         "100gbase-x-cpak",

+ 1 - 1
docs/customization/reports.md

@@ -111,7 +111,7 @@ The following methods are available to log results within a report:
 
 
 The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page.
 The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page.
 
 
-To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. The status of a completed report is available as `self.failed` and the results object is `self.result`.
+To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively.
 
 
 By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last.
 By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last.
 
 

+ 1 - 1
docs/installation/upgrading.md

@@ -59,7 +59,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
 
 
 ```no-highlight
 ```no-highlight
 # Set $OLDVER to the NetBox version currently installed
 # Set $OLDVER to the NetBox version currently installed
-NEWVER=3.4.9
+OLDVER=3.4.9
 sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
 sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
 sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
 sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
 sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
 sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/

+ 20 - 19
docs/reference/filtering.md

@@ -61,13 +61,14 @@ These lookup expressions can be applied by adding a suffix to the desired field'
 
 
 Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
 Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
 
 
-| Filter | Description |
-|--------|-------------|
-| `n` | Not equal to |
-| `lt` | Less than |
-| `lte` | Less than or equal to |
-| `gt` | Greater than |
-| `gte` | Greater than or equal to |
+| Filter  | Description              |
+|---------|--------------------------|
+| `n`     | Not equal to             |
+| `lt`    | Less than                |
+| `lte`   | Less than or equal to    |
+| `gt`    | Greater than             |
+| `gte`   | Greater than or equal to |
+| `empty` | Is empty/null (boolean)  |
 
 
 Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900:
 Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900:
 
 
@@ -79,18 +80,18 @@ GET /api/ipam/vlans/?vid__gt=900
 
 
 String based (char) fields (Name, Address, etc) support these lookup expressions:
 String based (char) fields (Name, Address, etc) support these lookup expressions:
 
 
-| Filter | Description |
-|--------|-------------|
-| `n` | Not equal to |
-| `ic` | Contains (case-insensitive) |
-| `nic` | Does not contain (case-insensitive) |
-| `isw` | Starts with (case-insensitive) |
-| `nisw` | Does not start with (case-insensitive) |
-| `iew` | Ends with (case-insensitive) |
-| `niew` | Does not end with (case-insensitive) |
-| `ie` | Exact match (case-insensitive) |
-| `nie` | Inverse exact match (case-insensitive) |
-| `empty` | Is empty (boolean) |
+| Filter  | Description                            |
+|---------|----------------------------------------|
+| `n`     | Not equal to                           |
+| `ic`    | Contains (case-insensitive)            |
+| `nic`   | Does not contain (case-insensitive)    |
+| `isw`   | Starts with (case-insensitive)         |
+| `nisw`  | Does not start with (case-insensitive) |
+| `iew`   | Ends with (case-insensitive)           |
+| `niew`  | Does not end with (case-insensitive)   |
+| `ie`    | Exact match (case-insensitive)         |
+| `nie`   | Inverse exact match (case-insensitive) |
+| `empty` | Is empty/null (boolean)                |
 
 
 Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
 Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
 
 

+ 27 - 1
docs/release-notes/version-3.5.md

@@ -1,6 +1,32 @@
 # NetBox v3.5
 # NetBox v3.5
 
 
-## v3.5.9 (FUTURE)
+## v3.5.9 (2023-08-28)
+
+### Enhancements
+
+* [#12489](https://github.com/netbox-community/netbox/issues/12489) - Dynamically render location and device lists under site and location views
+* [#12825](https://github.com/netbox-community/netbox/issues/12825) - Display assigned values count per obejct type under custom field view
+* [#13313](https://github.com/netbox-community/netbox/issues/13313) - Enable filtering IP ranges by containing prefix
+* [#13415](https://github.com/netbox-community/netbox/issues/13415) - Include request object in custom link renderer on tables
+* [#13536](https://github.com/netbox-community/netbox/issues/13536) - Move child VLANs list to a separate tab under VLAN group view
+* [#13542](https://github.com/netbox-community/netbox/issues/13542) - Pass additional HTTP headers through to custom script context
+* [#13585](https://github.com/netbox-community/netbox/issues/13585) - Introduce `empty` lookup for numeric value filters
+
+### Bug Fixes
+
+* [#11272](https://github.com/netbox-community/netbox/issues/11272) - Fix localization support for device position field
+* [#13358](https://github.com/netbox-community/netbox/issues/13358) - Git backend should send HTTP auth headers only if credentials have been defined
+* [#13477](https://github.com/netbox-community/netbox/issues/13477) - Fix filtering of modified objects after bulk import/update
+* [#13478](https://github.com/netbox-community/netbox/issues/13478) - Fix filtering of export templates by content type under web UI
+* [#13500](https://github.com/netbox-community/netbox/issues/13500) - Fix form validation for bulk update of L2VPN terminations via bulk import form
+* [#13503](https://github.com/netbox-community/netbox/issues/13503) - Fix utilization graph proportions when localization is enabled
+* [#13507](https://github.com/netbox-community/netbox/issues/13507) - Avoid raising exception for invalid content type during global search
+* [#13516](https://github.com/netbox-community/netbox/issues/13516) - Plugin utility functions should be importable from `extras.plugins`
+* [#13530](https://github.com/netbox-community/netbox/issues/13530) - Ensure script log messages can be serialized as JSON data
+* [#13543](https://github.com/netbox-community/netbox/issues/13543) - Config context tab under device/VM view should not require `extras.view_configcontext` permission
+* [#13544](https://github.com/netbox-community/netbox/issues/13544) - Ensure `reindex` command clears all cached values when not in lazy mode
+* [#13556](https://github.com/netbox-community/netbox/issues/13556) - Correct REST API representation of VDC status choice
+* [#13569](https://github.com/netbox-community/netbox/issues/13569) - Fix selection widgets for related interfaces when bulk editing interfaces under device view
 
 
 ---
 ---
 
 

+ 7 - 6
netbox/core/data_backends.py

@@ -125,12 +125,13 @@ class GitBackend(DataBackend):
         }
         }
 
 
         if self.url_scheme in ('http', 'https'):
         if self.url_scheme in ('http', 'https'):
-            clone_args.update(
-                {
-                    "username": self.params.get('username'),
-                    "password": self.params.get('password'),
-                }
-            )
+            if self.params.get('username'):
+                clone_args.update(
+                    {
+                        "username": self.params.get('username'),
+                        "password": self.params.get('password'),
+                    }
+                )
 
 
         logger.debug(f"Cloning git repo: {self.url}")
         logger.debug(f"Cloning git repo: {self.url}")
         try:
         try:

+ 1 - 0
netbox/dcim/api/serializers.py

@@ -758,6 +758,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
     primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
     primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
+    status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
 
 
     # Related object counts
     # Related object counts
     interface_count = serializers.IntegerField(read_only=True)
     interface_count = serializers.IntegerField(read_only=True)

+ 2 - 1
netbox/dcim/forms/model_forms.py

@@ -421,12 +421,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         label=_('Position'),
         label=_('Position'),
         required=False,
         required=False,
         help_text=_("The lowest-numbered unit occupied by the device"),
         help_text=_("The lowest-numbered unit occupied by the device"),
+        localize=True,
         widget=APISelect(
         widget=APISelect(
             api_url='/api/dcim/racks/{{rack}}/elevation/',
             api_url='/api/dcim/racks/{{rack}}/elevation/',
             attrs={
             attrs={
                 'disabled-indicator': 'device',
                 'disabled-indicator': 'device',
                 'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
                 'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
-            }
+            },
         )
         )
     )
     )
     device_type = DynamicModelChoiceField(
     device_type = DynamicModelChoiceField(

+ 0 - 33
netbox/dcim/views.py

@@ -398,32 +398,8 @@ class SiteView(generic.ObjectView):
             (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
             (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
         )
         )
 
 
-        locations = Location.objects.add_related_count(
-            Location.objects.all(),
-            Rack,
-            'location',
-            'rack_count',
-            cumulative=True
-        )
-        locations = Location.objects.add_related_count(
-            locations,
-            Device,
-            'location',
-            'device_count',
-            cumulative=True
-        ).restrict(request.user, 'view').filter(site=instance)
-
-        nonracked_devices = Device.objects.filter(
-            site=instance,
-            rack__isnull=True,
-            parent_bay__isnull=True
-        ).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
-
         return {
         return {
             'related_models': related_models,
             'related_models': related_models,
-            'locations': locations,
-            'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
-            'total_nonracked_devices_count': nonracked_devices.count(),
         }
         }
 
 
 
 
@@ -495,16 +471,8 @@ class LocationView(generic.ObjectView):
             (Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
             (Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
         )
         )
 
 
-        nonracked_devices = Device.objects.filter(
-            location=instance,
-            rack__isnull=True,
-            parent_bay__isnull=True
-        ).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
-
         return {
         return {
             'related_models': related_models,
             'related_models': related_models,
-            'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
-            'total_nonracked_devices_count': nonracked_devices.count(),
         }
         }
 
 
 
 
@@ -2055,7 +2023,6 @@ class DeviceConfigContextView(ObjectConfigContextView):
     base_template = 'dcim/device/base.html'
     base_template = 'dcim/device/base.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Config Context'),
         label=_('Config Context'),
-        permission='extras.view_configcontext',
         weight=2000
         weight=2000
     )
     )
 
 

+ 4 - 4
netbox/extras/forms/filtersets.py

@@ -136,7 +136,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id')),
         (None, ('q', 'filter_id')),
         (_('Data'), ('data_source_id', 'data_file_id')),
         (_('Data'), ('data_source_id', 'data_file_id')),
-        (_('Attributes'), ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
+        (_('Attributes'), ('content_type_id', 'mime_type', 'file_extension', 'as_attachment')),
     )
     )
     data_source_id = DynamicModelMultipleChoiceField(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -151,10 +151,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
             'source_id': '$data_source_id'
             'source_id': '$data_source_id'
         }
         }
     )
     )
-    content_types = ContentTypeMultipleChoiceField(
-        label=_('Content types'),
+    content_type_id = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
         queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
-        required=False
+        required=False,
+        label=_('Content types')
     )
     )
     mime_type = forms.CharField(
     mime_type = forms.CharField(
         required=False,
         required=False,

+ 1 - 4
netbox/extras/management/commands/reindex.py

@@ -69,10 +69,7 @@ class Command(BaseCommand):
         if not kwargs['lazy']:
         if not kwargs['lazy']:
             self.stdout.write('Clearing cached values... ', ending='')
             self.stdout.write('Clearing cached values... ', ending='')
             self.stdout.flush()
             self.stdout.flush()
-            content_types = [
-                ContentType.objects.get_for_model(model) for model in indexers.keys()
-            ]
-            deleted_count = search_backend.clear(content_types)
+            deleted_count = search_backend.clear()
             self.stdout.write(f'{deleted_count} entries deleted.')
             self.stdout.write(f'{deleted_count} entries deleted.')
 
 
         # Index models
         # Index models

+ 1 - 0
netbox/extras/plugins/__init__.py

@@ -11,6 +11,7 @@ from netbox.search import register_search
 from .navigation import *
 from .navigation import *
 from .registration import *
 from .registration import *
 from .templates import *
 from .templates import *
+from .utils import *
 
 
 # Initialize plugin registry
 # Initialize plugin registry
 registry['plugins'].update({
 registry['plugins'].update({

+ 5 - 5
netbox/extras/scripts.py

@@ -401,23 +401,23 @@ class BaseScript:
 
 
     def log_debug(self, message):
     def log_debug(self, message):
         self.logger.log(logging.DEBUG, message)
         self.logger.log(logging.DEBUG, message)
-        self.log.append((LogLevelChoices.LOG_DEFAULT, message))
+        self.log.append((LogLevelChoices.LOG_DEFAULT, str(message)))
 
 
     def log_success(self, message):
     def log_success(self, message):
         self.logger.log(logging.INFO, message)  # No syslog equivalent for SUCCESS
         self.logger.log(logging.INFO, message)  # No syslog equivalent for SUCCESS
-        self.log.append((LogLevelChoices.LOG_SUCCESS, message))
+        self.log.append((LogLevelChoices.LOG_SUCCESS, str(message)))
 
 
     def log_info(self, message):
     def log_info(self, message):
         self.logger.log(logging.INFO, message)
         self.logger.log(logging.INFO, message)
-        self.log.append((LogLevelChoices.LOG_INFO, message))
+        self.log.append((LogLevelChoices.LOG_INFO, str(message)))
 
 
     def log_warning(self, message):
     def log_warning(self, message):
         self.logger.log(logging.WARNING, message)
         self.logger.log(logging.WARNING, message)
-        self.log.append((LogLevelChoices.LOG_WARNING, message))
+        self.log.append((LogLevelChoices.LOG_WARNING, str(message)))
 
 
     def log_failure(self, message):
     def log_failure(self, message):
         self.logger.log(logging.ERROR, message)
         self.logger.log(logging.ERROR, message)
-        self.log.append((LogLevelChoices.LOG_FAILURE, message))
+        self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
 
 
     # Convenience functions
     # Convenience functions
 
 

+ 31 - 6
netbox/extras/tests/test_filtersets.py

@@ -1109,11 +1109,13 @@ class ChangeLoggedFilterSetTestCase(TestCase):
             Site(name='Site 1', slug='site-1'),
             Site(name='Site 1', slug='site-1'),
             Site(name='Site 2', slug='site-2'),
             Site(name='Site 2', slug='site-2'),
             Site(name='Site 3', slug='site-3'),
             Site(name='Site 3', slug='site-3'),
+            Site(name='Site 4', slug='site-4'),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
         # Simulate *creation* changelog records for two of the sites
         # Simulate *creation* changelog records for two of the sites
         request_id = uuid.uuid4()
         request_id = uuid.uuid4()
+        cls.create_request_id = request_id
         objectchanges = (
         objectchanges = (
             ObjectChange(
             ObjectChange(
                 changed_object_type=content_type,
                 changed_object_type=content_type,
@@ -1132,6 +1134,7 @@ class ChangeLoggedFilterSetTestCase(TestCase):
 
 
         # Simulate *update* changelog records for two of the sites
         # Simulate *update* changelog records for two of the sites
         request_id = uuid.uuid4()
         request_id = uuid.uuid4()
+        cls.update_request_id = request_id
         objectchanges = (
         objectchanges = (
             ObjectChange(
             ObjectChange(
                 changed_object_type=content_type,
                 changed_object_type=content_type,
@@ -1148,14 +1151,36 @@ class ChangeLoggedFilterSetTestCase(TestCase):
         )
         )
         ObjectChange.objects.bulk_create(objectchanges)
         ObjectChange.objects.bulk_create(objectchanges)
 
 
+        # Simulate *create* and *update* changelog records for two of the sites
+        request_id = uuid.uuid4()
+        cls.create_update_request_id = request_id
+        objectchanges = (
+            ObjectChange(
+                changed_object_type=content_type,
+                changed_object_id=sites[2].pk,
+                action=ObjectChangeActionChoices.ACTION_CREATE,
+                request_id=request_id
+            ),
+            ObjectChange(
+                changed_object_type=content_type,
+                changed_object_id=sites[3].pk,
+                action=ObjectChangeActionChoices.ACTION_UPDATE,
+                request_id=request_id
+            ),
+        )
+        ObjectChange.objects.bulk_create(objectchanges)
+
     def test_created_by_request(self):
     def test_created_by_request(self):
-        request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE).first().request_id
-        params = {'created_by_request': request_id}
+        params = {'created_by_request': self.create_request_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        self.assertEqual(self.queryset.count(), 3)
+        self.assertEqual(self.queryset.count(), 4)
 
 
     def test_updated_by_request(self):
     def test_updated_by_request(self):
-        request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE).first().request_id
-        params = {'updated_by_request': request_id}
+        params = {'updated_by_request': self.update_request_id}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.queryset.count(), 4)
+
+    def test_modified_by_request(self):
+        params = {'modified_by_request': self.create_update_request_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        self.assertEqual(self.queryset.count(), 3)
+        self.assertEqual(self.queryset.count(), 4)

+ 15 - 0
netbox/extras/views.py

@@ -46,6 +46,21 @@ class CustomFieldListView(generic.ObjectListView):
 class CustomFieldView(generic.ObjectView):
 class CustomFieldView(generic.ObjectView):
     queryset = CustomField.objects.select_related('choice_set')
     queryset = CustomField.objects.select_related('choice_set')
 
 
+    def get_extra_context(self, request, instance):
+        related_models = ()
+
+        for content_type in instance.content_types.all():
+            related_models += (
+                content_type.model_class().objects.restrict(request.user, 'view').exclude(
+                    Q(**{f'custom_field_data__{instance.name}': ''}) |
+                    Q(**{f'custom_field_data__{instance.name}': None})
+                ),
+            )
+
+        return {
+            'related_models': related_models
+        }
+
 
 
 @register_model_view(CustomField, 'edit')
 @register_model_view(CustomField, 'edit')
 class CustomFieldEditView(generic.ObjectEditView):
 class CustomFieldEditView(generic.ObjectEditView):

+ 16 - 0
netbox/ipam/filtersets.py

@@ -467,6 +467,10 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         choices=IPRangeStatusChoices,
         choices=IPRangeStatusChoices,
         null_value=None
         null_value=None
     )
     )
+    parent = MultiValueCharFilter(
+        method='search_by_parent',
+        label=_('Parent prefix'),
+    )
 
 
     class Meta:
     class Meta:
         model = IPRange
         model = IPRange
@@ -501,6 +505,18 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         except ValidationError:
         except ValidationError:
             return queryset.none()
             return queryset.none()
 
 
+    def search_by_parent(self, queryset, name, value):
+        if not value:
+            return queryset
+        q = Q()
+        for prefix in value:
+            try:
+                query = str(netaddr.IPNetwork(prefix.strip()).cidr)
+                q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query)
+            except (AddrFormatError, ValueError):
+                return queryset.none()
+        return queryset.filter(q)
+
 
 
 class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     family = django_filters.NumberFilter(
     family = django_filters.NumberFilter(

+ 4 - 2
netbox/ipam/forms/bulk_import.py

@@ -592,9 +592,11 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
 
 
         if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
         if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
             raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
             raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
-        if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
+        if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
             raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
             raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
         if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
         if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
             raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
             raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
 
 
-        self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')
+        # if this is an update we might not have interface or vlan in the form data
+        if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'):
+            self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')

+ 6 - 0
netbox/ipam/models/vlans.py

@@ -118,6 +118,12 @@ class VLANGroup(OrganizationalModel):
             return available_vids[0]
             return available_vids[0]
         return None
         return None
 
 
+    def get_child_vlans(self):
+        """
+        Return all VLANs within this group.
+        """
+        return VLAN.objects.filter(group=self).order_by('vid')
+
 
 
 class VLAN(PrimaryModel):
 class VLAN(PrimaryModel):
     """
     """

+ 6 - 1
netbox/ipam/tests/test_filtersets.py

@@ -10,7 +10,6 @@ from ipam.models import *
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from rest_framework import serializers
 
 
 
 
 class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -807,6 +806,12 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_parent(self):
+        params = {'parent': ['10.0.1.0/24', '10.0.2.0/24']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'parent': ['10.0.1.0/25']}  # Range 10.0.1.100-199 is not fully contained by 10.0.1.0/25
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
 
 
 class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
 class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPAddress.objects.all()
     queryset = IPAddress.objects.all()

+ 24 - 13
netbox/ipam/views.py

@@ -897,21 +897,8 @@ class VLANGroupView(generic.ObjectView):
             (VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
             (VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
         )
         )
 
 
-        # TODO: Replace with embedded table
-        vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
-            Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)),
-            'tenant', 'site', 'role',
-        ).order_by('vid')
-        vlans = add_available_vlans(vlans, vlan_group=instance)
-
-        vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',))
-        if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
-            vlans_table.columns.show('pk')
-        vlans_table.configure(request)
-
         return {
         return {
             'related_models': related_models,
             'related_models': related_models,
-            'vlans_table': vlans_table,
         }
         }
 
 
 
 
@@ -944,6 +931,30 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
 
 
 
 
+@register_model_view(VLANGroup, 'vlans')
+class VLANGroupVLANsView(generic.ObjectChildrenView):
+    queryset = VLANGroup.objects.all()
+    child_model = VLAN
+    table = tables.VLANTable
+    filterset = filtersets.VLANFilterSet
+    template_name = 'generic/object_children.html'
+    tab = ViewTab(
+        label=_('VLANs'),
+        badge=lambda x: x.get_child_vlans().count(),
+        permission='ipam.view_vlan',
+        weight=500
+    )
+
+    def get_children(self, request, parent):
+        return parent.get_child_vlans().restrict(request.user, 'view').prefetch_related(
+            Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)),
+            'tenant', 'site', 'role',
+        )
+
+    def prep_table_data(self, request, queryset, parent):
+        return add_available_vlans(parent.get_child_vlans(), parent)
+
+
 #
 #
 # FHRP groups
 # FHRP groups
 #
 #

+ 8 - 4
netbox/netbox/filtersets.py

@@ -246,18 +246,22 @@ class ChangeLoggedModelFilterSet(BaseFilterSet):
     updated_by_request = django_filters.UUIDFilter(
     updated_by_request = django_filters.UUIDFilter(
         method='filter_by_request'
         method='filter_by_request'
     )
     )
+    modified_by_request = django_filters.UUIDFilter(
+        method='filter_by_request'
+    )
 
 
     def filter_by_request(self, queryset, name, value):
     def filter_by_request(self, queryset, name, value):
         content_type = ContentType.objects.get_for_model(self.Meta.model)
         content_type = ContentType.objects.get_for_model(self.Meta.model)
         action = {
         action = {
-            'created_by_request': ObjectChangeActionChoices.ACTION_CREATE,
-            'updated_by_request': ObjectChangeActionChoices.ACTION_UPDATE,
+            'created_by_request': Q(action=ObjectChangeActionChoices.ACTION_CREATE),
+            'updated_by_request': Q(action=ObjectChangeActionChoices.ACTION_UPDATE),
+            'modified_by_request': Q(action__in=[ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]),
         }.get(name)
         }.get(name)
         request_id = value
         request_id = value
         pks = ObjectChange.objects.filter(
         pks = ObjectChange.objects.filter(
+            action,
             changed_object_type=content_type,
             changed_object_type=content_type,
-            action=action,
-            request_id=request_id
+            request_id=request_id,
         ).values_list('changed_object_id', flat=True)
         ).values_list('changed_object_id', flat=True)
         return queryset.filter(pk__in=pks)
         return queryset.filter(pk__in=pks)
 
 

+ 19 - 10
netbox/netbox/tables/columns.py

@@ -4,6 +4,7 @@ from urllib.parse import quote
 
 
 import django_tables2 as tables
 import django_tables2 as tables
 from django.conf import settings
 from django.conf import settings
+from django.contrib.auth.context_processors import auth
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.auth.models import AnonymousUser
 from django.db.models import DateField, DateTimeField
 from django.db.models import DateField, DateTimeField
 from django.template import Context, Template
 from django.template import Context, Template
@@ -517,24 +518,32 @@ class CustomLinkColumn(tables.Column):
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-    def render(self, record):
-        try:
-            rendered = self.customlink.render({
-                'object': record,
+    def _render_customlink(self, record, table):
+        context = {
+            'object': record,
+            'debug': settings.DEBUG,
+        }
+        if request := getattr(table, 'context', {}).get('request'):
+            # If the request is available, include it as context
+            context.update({
+                'request': request,
+                **auth(request),
             })
             })
-            if rendered:
+
+        return self.customlink.render(context)
+
+    def render(self, record, table, **kwargs):
+        try:
+            if rendered := self._render_customlink(record, table):
                 return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
                 return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
         except Exception as e:
         except Exception as e:
             error_text = _('Error')
             error_text = _('Error')
             return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
             return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
         return ''
         return ''
 
 
-    def value(self, record):
+    def value(self, record, table, **kwargs):
         try:
         try:
-            rendered = self.customlink.render({
-                'object': record,
-            })
-            if rendered:
+            if rendered := self._render_customlink(record, table):
                 return rendered['link']
                 return rendered['link']
         except Exception:
         except Exception:
             pass
             pass

+ 1 - 1
netbox/netbox/views/generic/bulk_views.py

@@ -465,7 +465,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                     messages.success(request, msg)
                     messages.success(request, msg)
 
 
                     view_name = get_viewname(model, action='list')
                     view_name = get_viewname(model, action='list')
-                    results_url = f"{reverse(view_name)}?created_by_request={request.id}"
+                    results_url = f"{reverse(view_name)}?modified_by_request={request.id}"
                     return redirect(results_url)
                     return redirect(results_url)
 
 
             except (AbortTransaction, ValidationError):
             except (AbortTransaction, ValidationError):

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/graphiql.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/graphiql.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/graphiql.js.map


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

@@ -6,7 +6,7 @@
   "license": "Apache-2.0",
   "license": "Apache-2.0",
   "private": true,
   "private": true,
   "dependencies": {
   "dependencies": {
-    "graphiql": "1.4.1",
+    "graphiql": "1.8.9",
     "graphql": ">= v14.5.0 <= 15.5.0",
     "graphql": ">= v14.5.0 <= 15.5.0",
     "react": "17.0.2",
     "react": "17.0.2",
     "react-dom": "17.0.2",
     "react-dom": "17.0.2",

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 77 - 608
netbox/project-static/yarn.lock


+ 9 - 1
netbox/templates/dcim/device/components_base.html

@@ -2,7 +2,15 @@
 {% load helpers %}
 {% load helpers %}
 
 
 {% block bulk_edit_controls %}
 {% block bulk_edit_controls %}
-    {{ block.super }}
+    {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
+        {% if 'bulk_edit' in actions and bulk_edit_view %}
+            <button type="submit" name="_edit"
+                    formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
+                    class="btn btn-warning btn-sm">
+                <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
+            </button>
+        {% endif %}
+    {% endwith %}
     {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
     {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
         {% if 'bulk_rename' in actions and bulk_rename_view %}
         {% if 'bulk_rename' in actions and bulk_rename_view %}
             <button type="submit" name="_rename"
             <button type="submit" name="_rename"

+ 0 - 83
netbox/templates/dcim/inc/nonracked_devices.html

@@ -1,83 +0,0 @@
-{% load helpers %}
-{% load i18n %}
-
-<div class="card">
-    <h5 class="card-header">
-        {% trans "Non-Racked Devices" %}
-    </h5>
-    <div class="card-body">
-    {% if nonracked_devices %}
-        <table class="table table-hover">
-            <tr>
-                <th>{% trans "Name" %}</th>
-                <th>{% trans "Role" %}</th>
-                <th>{% trans "Type" %}</th>
-                <th colspan="2">{% trans "Parent Device" %}</th>
-            </tr>
-            {% for device in nonracked_devices %}
-            <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
-                <td>
-                    <a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
-                </td>
-                <td>{{ device.role }}</td>
-                <td>{{ device.device_type }}</td>
-                {% if device.parent_bay %}
-                    <td>{{ device.parent_bay.device|linkify }}</td>
-                    <td>{{ device.parent_bay }}</td>
-                {% else %}
-                    <td colspan="2" class="text-muted">&mdash;</td>
-                {% endif %}
-            </tr>
-            {% endfor %}
-        </table>
-
-        {%  if total_nonracked_devices_count > nonracked_devices.count %}
-            {% if object|meta:'verbose_name' == 'site' %}
-                <div class="text-muted">
-                  {% blocktrans with count=nonracked_devices.count total=total_nonracked_devices_count %}
-                    Displaying {{ count }} of {{ total }} devices
-                  {% endblocktrans %}
-                  (<a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}&rack_id=null">{% trans "View full list" %}</a>)
-                </div>
-            {% elif object|meta:'verbose_name' == 'location' %}
-                <div class="text-muted">
-                  {% blocktrans with count=nonracked_devices.count total=total_nonracked_devices_count %}
-                    Displaying {{ count }} of {{ total }} devices
-                  {% endblocktrans %}
-                  (<a href="{% url 'dcim:device_list' %}?location_id={{ object.pk }}&rack_id=null">{% trans "View full list" %}</a>)
-                </div>
-            {% endif %}
-        {% endif %}
-
-    {% else %}
-        <div class="text-muted">
-            {% trans "None" %}
-        </div>
-    {% endif %}
-    </div>
-
-    {% if perms.dcim.add_device %}
-        {% if object|meta:'verbose_name' == 'rack' %}
-        <div class="card-footer text-end noprint">
-            <a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&rack={{ object.pk }}" class="btn btn-primary btn-sm">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
-                {% trans "Add a Non-Racked Device" %}
-            </a>
-        </div>
-        {% elif object|meta:'verbose_name' == 'site' %}
-        <div class="card-footer text-end noprint">
-            <a href="{% url 'dcim:device_add' %}?site={{ object.pk }}" class="btn btn-primary btn-sm">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
-                {% trans "Add a Non-Racked Device" %}
-            </a>
-        </div>
-        {% elif object|meta:'verbose_name' == 'location' %}
-        <div class="card-footer text-end noprint">
-            <a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}" class="btn btn-primary btn-sm">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
-                {% trans "Add a Non-Racked Device" %}
-            </a>
-        </div>
-        {% endif %}
-    {% endif %}
-</div>

+ 21 - 1
netbox/templates/dcim/location.html

@@ -66,7 +66,6 @@
   </div>
   </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/related_objects.html' %}
-    {% include 'dcim/inc/nonracked_devices.html' %}
     {% include 'inc/panels/image_attachments.html' %}
     {% include 'inc/panels/image_attachments.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
 	</div>
 	</div>
@@ -79,6 +78,27 @@
         hx-get="{% url 'dcim:location_list' %}?parent_id={{ object.pk }}"
         hx-get="{% url 'dcim:location_list' %}?parent_id={{ object.pk }}"
         hx-trigger="load"
         hx-trigger="load"
       ></div>
       ></div>
+      {% if perms.dcim.add_location %}
+        <div class="card-footer text-end noprint">
+          <a href="{% url 'dcim:location_add' %}?site={{ object.site.pk }}&parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Location" %}
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    <div class="card">
+      <h5 class="card-header">Non-Racked Devices</h5>
+      <div class="card-body htmx-container table-responsive"
+        hx-get="{% url 'dcim:device_list' %}?location_id={{ object.pk }}&rack_id=null&parent_bay_id=null"
+        hx-trigger="load"
+      ></div>
+      {% if perms.dcim.add_device %}
+        <div class="card-footer text-end noprint">
+          <a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device" %}
+          </a>
+        </div>
+      {% endif %}
     </div>
     </div>
     {% plugin_full_width_page object %}
     {% plugin_full_width_page object %}
   </div>
   </div>

+ 28 - 44
netbox/templates/dcim/site.html

@@ -132,56 +132,40 @@
     </div>
     </div>
     <div class="col col-md-6">
     <div class="col col-md-6">
       {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
       {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
-      <div class="card">
-        <h5 class="card-header">{% trans "Locations" %}</h5>
-        <div class='card-body'>
-          {% if locations %}
-            <table class="table table-hover">
-              <tr>
-                <th>{% trans "Location" %}</th>
-                <th>{% trans "Racks" %}</th>
-                <th>{% trans "Devices" %}</th>
-                <th></th>
-              </tr>
-              {% for location in locations %}
-                <tr>
-                  <td>
-                    {% for i in location.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
-                    {{ location|linkify }}
-                  </td>
-                  <td>
-                    <a href="{% url 'dcim:rack_list' %}?location_id={{ location.pk }}">{{ location.rack_count }}</a>
-                  </td>
-                  <td>
-                    <a href="{% url 'dcim:device_list' %}?location_id={{ location.pk }}">{{ location.device_count }}</a>
-                  </td>
-                  <td class="text-end noprint">
-                    <a href="{% url 'dcim:rack_elevation_list' %}?location_id={{ location.pk }}" class="btn btn-sm btn-primary" title="{% trans "View Elevations" %}">
-                      <i class="mdi mdi-server"></i>
-                    </a>
-                  </td>
-                </tr>
-              {% endfor %}
-            </table>
-          {% else %}
-            <span class="text-muted">{% trans "None" %}</span>
-          {% endif %}
-        </div>
-        {% if perms.dcim.add_location %}
-          <div class="card-footer text-end noprint">
-            <a href="{% url 'dcim:location_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
-              <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a location" %}
-            </a>
-          </div>
-        {% endif %}
-      </div>
       {% include 'inc/panels/image_attachments.html' %}
       {% include 'inc/panels/image_attachments.html' %}
       {% plugin_right_page object %}
       {% plugin_right_page object %}
 	</div>
 	</div>
 </div>
 </div>
 <div class="row">
 <div class="row">
   <div class="col col-md-12">
   <div class="col col-md-12">
-    {% include 'dcim/inc/nonracked_devices.html' %}
+    <div class="card">
+      <h5 class="card-header">Locations</h5>
+      <div class="card-body htmx-container table-responsive"
+        hx-get="{% url 'dcim:location_list' %}?site_id={{ object.pk }}"
+        hx-trigger="load"
+      ></div>
+      {% if perms.dcim.add_location %}
+        <div class="card-footer text-end noprint">
+          <a href="{% url 'dcim:location_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Location" %}
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    <div class="card">
+      <h5 class="card-header">Non-Racked Devices</h5>
+      <div class="card-body htmx-container table-responsive"
+        hx-get="{% url 'dcim:device_list' %}?site_id={{ object.pk }}&rack_id=null&parent_bay_id=null"
+        hx-trigger="load"
+      ></div>
+      {% if perms.dcim.add_device %}
+        <div class="card-footer text-end noprint">
+          <a href="{% url 'dcim:device_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device" %}
+          </a>
+        </div>
+      {% endif %}
+    </div>
     {% plugin_full_width_page object %}
     {% plugin_full_width_page object %}
   </div>
   </div>
 </div>
 </div>

+ 18 - 0
netbox/templates/extras/customfield.html

@@ -125,6 +125,24 @@
         </table>
         </table>
       </div>
       </div>
     </div>
     </div>
+    <div class="card">
+      <h5 class="card-header">Related Objects</h5>
+      <ul class="list-group list-group-flush">
+        {% for qs in related_models %}
+          <a class="list-group-item list-group-item-action d-flex justify-content-between">
+            {{ qs.model|meta:"verbose_name_plural"|bettertitle }}
+            {% with count=qs.count %}
+              {% if count %}
+                <span class="badge bg-primary rounded-pill">{{ count }}</span>
+              {% else %}
+                <span class="badge bg-light rounded-pill">&mdash;</span>
+              {% endif %}
+            {% endwith %}
+          </a>
+        {% endfor %}
+      </ul>
+    </div>
+
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>
 </div>
 </div>

+ 0 - 11
netbox/templates/ipam/vlangroup.html

@@ -59,15 +59,4 @@
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>
 </div>
 </div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    <div class="card">
-      <h5 class="card-header">{% trans "VLANs" %}</h5>
-      <div class="card-body table-responsive">
-        {% render_table vlans_table 'inc/table.html' %}
-        {% include 'inc/paginator.html' with paginator=vlans_table.paginator page=vlans_table.page %}
-      </div>
-    </div>
-  </div>
-</div>
 {% endblock %}
 {% endblock %}

+ 7 - 1
netbox/users/api/views.py

@@ -60,7 +60,13 @@ class TokenProvisionView(APIView):
     """
     """
     permission_classes = []
     permission_classes = []
 
 
-    # @extend_schema(methods=["post"], responses={201: serializers.TokenSerializer})
+    @extend_schema(
+        request=serializers.TokenProvisionSerializer,
+        responses={
+            201: serializers.TokenSerializer,
+            401: OpenApiTypes.OBJECT,
+        }
+    )
     def post(self, request):
     def post(self, request):
         serializer = serializers.TokenProvisionSerializer(data=request.data)
         serializer = serializers.TokenProvisionSerializer(data=request.data)
         serializer.is_valid()
         serializer.is_valid()

+ 6 - 1
netbox/utilities/constants.py

@@ -20,7 +20,8 @@ FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
     lte='lte',
     lte='lte',
     lt='lt',
     lt='lt',
     gte='gte',
     gte='gte',
-    gt='gt'
+    gt='gt',
+    empty='isnull',
 )
 )
 
 
 FILTER_NEGATION_LOOKUP_MAP = dict(
 FILTER_NEGATION_LOOKUP_MAP = dict(
@@ -45,6 +46,10 @@ HTTP_REQUEST_META_SAFE_COPY = [
     'HTTP_REFERER',
     'HTTP_REFERER',
     'HTTP_USER_AGENT',
     'HTTP_USER_AGENT',
     'HTTP_X_FORWARDED_FOR',
     'HTTP_X_FORWARDED_FOR',
+    'HTTP_X_FORWARDED_HOST',
+    'HTTP_X_FORWARDED_PORT',
+    'HTTP_X_FORWARDED_PROTO',
+    'HTTP_X_REAL_IP',
     'QUERY_STRING',
     'QUERY_STRING',
     'REMOTE_ADDR',
     'REMOTE_ADDR',
     'REMOTE_HOST',
     'REMOTE_HOST',

+ 11 - 6
netbox/utilities/fields.py

@@ -105,6 +105,10 @@ class RestrictedGenericForeignKey(GenericForeignKey):
             # We avoid looking for values if either ct_id or fkey value is None
             # We avoid looking for values if either ct_id or fkey value is None
             ct_id = getattr(instance, ct_attname)
             ct_id = getattr(instance, ct_attname)
             if ct_id is not None:
             if ct_id is not None:
+                # Check if the content type actually exists
+                if not self.get_content_type(id=ct_id, using=instance._state.db).model_class():
+                    continue
+
                 fk_val = getattr(instance, self.fk_field)
                 fk_val = getattr(instance, self.fk_field)
                 if fk_val is not None:
                 if fk_val is not None:
                     fk_dict[ct_id].add(fk_val)
                     fk_dict[ct_id].add(fk_val)
@@ -129,13 +133,14 @@ class RestrictedGenericForeignKey(GenericForeignKey):
             if ct_id is None:
             if ct_id is None:
                 return None
                 return None
             else:
             else:
-                model = self.get_content_type(
+                if model := self.get_content_type(
                     id=ct_id, using=obj._state.db
                     id=ct_id, using=obj._state.db
-                ).model_class()
-                return (
-                    model._meta.pk.get_prep_value(getattr(obj, self.fk_field)),
-                    model,
-                )
+                ).model_class():
+                    return (
+                        model._meta.pk.get_prep_value(getattr(obj, self.fk_field)),
+                        model,
+                    )
+                return None
 
 
         return (
         return (
             ret_val,
             ret_val,

+ 2 - 1
netbox/utilities/templates/helpers/utilization_graph.html

@@ -1,3 +1,4 @@
+{% load l10n %}
 <div class="progress">
 <div class="progress">
   <div
   <div
     role="progressbar"
     role="progressbar"
@@ -5,7 +6,7 @@
     aria-valuemax="100"
     aria-valuemax="100"
     aria-valuenow="{{ utilization }}"
     aria-valuenow="{{ utilization }}"
     class="progress-bar {{ bar_class }}"
     class="progress-bar {{ bar_class }}"
-    style="width: {{ utilization }}%;"
+    style="width: {{ utilization|unlocalize }}%;"
   >
   >
     {% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
     {% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
   </div>
   </div>

+ 25 - 0
netbox/utilities/tests/test_filters.py

@@ -86,6 +86,10 @@ class DummyModel(models.Model):
     charfield = models.CharField(
     charfield = models.CharField(
         max_length=10
         max_length=10
     )
     )
+    numberfield = models.IntegerField(
+        blank=True,
+        null=True
+    )
     choicefield = models.IntegerField(
     choicefield = models.IntegerField(
         choices=(('A', 1), ('B', 2), ('C', 3))
         choices=(('A', 1), ('B', 2), ('C', 3))
     )
     )
@@ -108,6 +112,7 @@ class BaseFilterSetTest(TestCase):
     """
     """
     class DummyFilterSet(BaseFilterSet):
     class DummyFilterSet(BaseFilterSet):
         charfield = django_filters.CharFilter()
         charfield = django_filters.CharFilter()
+        numberfield = django_filters.NumberFilter()
         macaddressfield = MACAddressFilter()
         macaddressfield = MACAddressFilter()
         modelchoicefield = django_filters.ModelChoiceFilter(
         modelchoicefield = django_filters.ModelChoiceFilter(
             field_name='integerfield',  # We're pretending this is a ForeignKey field
             field_name='integerfield',  # We're pretending this is a ForeignKey field
@@ -132,6 +137,7 @@ class BaseFilterSetTest(TestCase):
             model = DummyModel
             model = DummyModel
             fields = (
             fields = (
                 'charfield',
                 'charfield',
+                'numberfield',
                 'choicefield',
                 'choicefield',
                 'datefield',
                 'datefield',
                 'datetimefield',
                 'datetimefield',
@@ -171,6 +177,25 @@ class BaseFilterSetTest(TestCase):
         self.assertEqual(self.filters['charfield__iew'].exclude, False)
         self.assertEqual(self.filters['charfield__iew'].exclude, False)
         self.assertEqual(self.filters['charfield__niew'].lookup_expr, 'iendswith')
         self.assertEqual(self.filters['charfield__niew'].lookup_expr, 'iendswith')
         self.assertEqual(self.filters['charfield__niew'].exclude, True)
         self.assertEqual(self.filters['charfield__niew'].exclude, True)
+        self.assertEqual(self.filters['charfield__empty'].lookup_expr, 'empty')
+        self.assertEqual(self.filters['charfield__empty'].exclude, False)
+
+    def test_number_filter(self):
+        self.assertIsInstance(self.filters['numberfield'], django_filters.NumberFilter)
+        self.assertEqual(self.filters['numberfield'].lookup_expr, 'exact')
+        self.assertEqual(self.filters['numberfield'].exclude, False)
+        self.assertEqual(self.filters['numberfield__n'].lookup_expr, 'exact')
+        self.assertEqual(self.filters['numberfield__n'].exclude, True)
+        self.assertEqual(self.filters['numberfield__lt'].lookup_expr, 'lt')
+        self.assertEqual(self.filters['numberfield__lt'].exclude, False)
+        self.assertEqual(self.filters['numberfield__lte'].lookup_expr, 'lte')
+        self.assertEqual(self.filters['numberfield__lte'].exclude, False)
+        self.assertEqual(self.filters['numberfield__gt'].lookup_expr, 'gt')
+        self.assertEqual(self.filters['numberfield__gt'].exclude, False)
+        self.assertEqual(self.filters['numberfield__gte'].lookup_expr, 'gte')
+        self.assertEqual(self.filters['numberfield__gte'].exclude, False)
+        self.assertEqual(self.filters['numberfield__empty'].lookup_expr, 'isnull')
+        self.assertEqual(self.filters['numberfield__empty'].exclude, False)
 
 
     def test_mac_address_filter(self):
     def test_mac_address_filter(self):
         self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter)
         self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter)

+ 0 - 1
netbox/virtualization/views.py

@@ -387,7 +387,6 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
     base_template = 'virtualization/virtualmachine.html'
     base_template = 'virtualization/virtualmachine.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Config Context'),
         label=_('Config Context'),
-        permission='extras.view_configcontext',
         weight=2000
         weight=2000
     )
     )
 
 

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно