Kaynağa Gözat

Merge branch 'develop' into feature

Jeremy Stretch 2 yıl önce
ebeveyn
işleme
2a4e3dd09f
42 değiştirilmiş dosya ile 408 ekleme ve 873 silme
  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:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.5.8
+      placeholder: v3.5.9
     validations:
       required: true
   - type: dropdown

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

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

+ 1 - 0
contrib/generated_schema.json

@@ -332,6 +332,7 @@
                         "100gbase-x-cfp",
                         "100gbase-x-cfp2",
                         "200gbase-x-cfp2",
+                        "400gbase-x-cfp2",
                         "100gbase-x-cfp4",
                         "100gbase-x-cxp",
                         "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.
 
-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.
 

+ 1 - 1
docs/installation/upgrading.md

@@ -59,7 +59,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
 
 ```no-highlight
 # 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/netbox/netbox/configuration.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:
 
-| 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:
 
@@ -79,18 +80,18 @@ GET /api/ipam/vlans/?vid__gt=900
 
 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:
 

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

@@ -1,6 +1,32 @@
 # 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'):
-            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}")
         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_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
+    status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
 
     # Related object counts
     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'),
         required=False,
         help_text=_("The lowest-numbered unit occupied by the device"),
+        localize=True,
         widget=APISelect(
             api_url='/api/dcim/racks/{{rack}}/elevation/',
             attrs={
                 'disabled-indicator': 'device',
                 'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
-            }
+            },
         )
     )
     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'),
         )
 
-        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 {
             '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'),
         )
 
-        nonracked_devices = Device.objects.filter(
-            location=instance,
-            rack__isnull=True,
-            parent_bay__isnull=True
-        ).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
-
         return {
             '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'
     tab = ViewTab(
         label=_('Config Context'),
-        permission='extras.view_configcontext',
         weight=2000
     )
 

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

@@ -136,7 +136,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
         (None, ('q', 'filter_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(
         queryset=DataSource.objects.all(),
@@ -151,10 +151,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
             'source_id': '$data_source_id'
         }
     )
-    content_types = ContentTypeMultipleChoiceField(
-        label=_('Content types'),
+    content_type_id = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
-        required=False
+        required=False,
+        label=_('Content types')
     )
     mime_type = forms.CharField(
         required=False,

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

@@ -69,10 +69,7 @@ class Command(BaseCommand):
         if not kwargs['lazy']:
             self.stdout.write('Clearing cached values... ', ending='')
             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.')
 
         # Index models

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

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

+ 5 - 5
netbox/extras/scripts.py

@@ -401,23 +401,23 @@ class BaseScript:
 
     def log_debug(self, 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):
         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):
         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):
         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):
         self.logger.log(logging.ERROR, message)
-        self.log.append((LogLevelChoices.LOG_FAILURE, message))
+        self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
 
     # 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 2', slug='site-2'),
             Site(name='Site 3', slug='site-3'),
+            Site(name='Site 4', slug='site-4'),
         )
         Site.objects.bulk_create(sites)
 
         # Simulate *creation* changelog records for two of the sites
         request_id = uuid.uuid4()
+        cls.create_request_id = request_id
         objectchanges = (
             ObjectChange(
                 changed_object_type=content_type,
@@ -1132,6 +1134,7 @@ class ChangeLoggedFilterSetTestCase(TestCase):
 
         # Simulate *update* changelog records for two of the sites
         request_id = uuid.uuid4()
+        cls.update_request_id = request_id
         objectchanges = (
             ObjectChange(
                 changed_object_type=content_type,
@@ -1148,14 +1151,36 @@ class ChangeLoggedFilterSetTestCase(TestCase):
         )
         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):
-        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.queryset.count(), 3)
+        self.assertEqual(self.queryset.count(), 4)
 
     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.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):
     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')
 class CustomFieldEditView(generic.ObjectEditView):

+ 16 - 0
netbox/ipam/filtersets.py

@@ -467,6 +467,10 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         choices=IPRangeStatusChoices,
         null_value=None
     )
+    parent = MultiValueCharFilter(
+        method='search_by_parent',
+        label=_('Parent prefix'),
+    )
 
     class Meta:
         model = IPRange
@@ -501,6 +505,18 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         except ValidationError:
             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):
     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'):
             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.'))
         if self.cleaned_data.get('interface') and self.cleaned_data.get('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 None
 
+    def get_child_vlans(self):
+        """
+        Return all VLANs within this group.
+        """
+        return VLAN.objects.filter(group=self).order_by('vid')
+
 
 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 virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
-from rest_framework import serializers
 
 
 class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -807,6 +806,12 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         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):
     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'),
         )
 
-        # 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 {
             'related_models': related_models,
-            'vlans_table': vlans_table,
         }
 
 
@@ -944,6 +931,30 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     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
 #

+ 8 - 4
netbox/netbox/filtersets.py

@@ -246,18 +246,22 @@ class ChangeLoggedModelFilterSet(BaseFilterSet):
     updated_by_request = django_filters.UUIDFilter(
         method='filter_by_request'
     )
+    modified_by_request = django_filters.UUIDFilter(
+        method='filter_by_request'
+    )
 
     def filter_by_request(self, queryset, name, value):
         content_type = ContentType.objects.get_for_model(self.Meta.model)
         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)
         request_id = value
         pks = ObjectChange.objects.filter(
+            action,
             changed_object_type=content_type,
-            action=action,
-            request_id=request_id
+            request_id=request_id,
         ).values_list('changed_object_id', flat=True)
         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
 from django.conf import settings
+from django.contrib.auth.context_processors import auth
 from django.contrib.auth.models import AnonymousUser
 from django.db.models import DateField, DateTimeField
 from django.template import Context, Template
@@ -517,24 +518,32 @@ class CustomLinkColumn(tables.Column):
 
         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>')
         except Exception as e:
             error_text = _('Error')
             return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
         return ''
 
-    def value(self, record):
+    def value(self, record, table, **kwargs):
         try:
-            rendered = self.customlink.render({
-                'object': record,
-            })
-            if rendered:
+            if rendered := self._render_customlink(record, table):
                 return rendered['link']
         except Exception:
             pass

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

@@ -465,7 +465,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                     messages.success(request, msg)
 
                     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)
 
             except (AbortTransaction, ValidationError):

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/graphiql.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/graphiql.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 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",
   "private": true,
   "dependencies": {
-    "graphiql": "1.4.1",
+    "graphiql": "1.8.9",
     "graphql": ">= v14.5.0 <= 15.5.0",
     "react": "17.0.2",
     "react-dom": "17.0.2",

Dosya farkı çok büyük olduğundan ihmal edildi
+ 77 - 608
netbox/project-static/yarn.lock


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

@@ -2,7 +2,15 @@
 {% load helpers %}
 
 {% 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" %}
         {% if 'bulk_rename' in actions and bulk_rename_view %}
             <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 class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
-    {% include 'dcim/inc/nonracked_devices.html' %}
     {% include 'inc/panels/image_attachments.html' %}
     {% plugin_right_page object %}
 	</div>
@@ -79,6 +78,27 @@
         hx-get="{% url 'dcim:location_list' %}?parent_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.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>
     {% plugin_full_width_page object %}
   </div>

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

@@ -132,56 +132,40 @@
     </div>
     <div class="col col-md-6">
       {% 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' %}
       {% plugin_right_page object %}
 	</div>
 </div>
 <div class="row">
   <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 %}
   </div>
 </div>

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

@@ -125,6 +125,24 @@
         </table>
       </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 %}
   </div>
 </div>

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

@@ -59,15 +59,4 @@
     {% plugin_right_page object %}
   </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 %}

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

@@ -60,7 +60,13 @@ class TokenProvisionView(APIView):
     """
     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):
         serializer = serializers.TokenProvisionSerializer(data=request.data)
         serializer.is_valid()

+ 6 - 1
netbox/utilities/constants.py

@@ -20,7 +20,8 @@ FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
     lte='lte',
     lt='lt',
     gte='gte',
-    gt='gt'
+    gt='gt',
+    empty='isnull',
 )
 
 FILTER_NEGATION_LOOKUP_MAP = dict(
@@ -45,6 +46,10 @@ HTTP_REQUEST_META_SAFE_COPY = [
     'HTTP_REFERER',
     'HTTP_USER_AGENT',
     'HTTP_X_FORWARDED_FOR',
+    'HTTP_X_FORWARDED_HOST',
+    'HTTP_X_FORWARDED_PORT',
+    'HTTP_X_FORWARDED_PROTO',
+    'HTTP_X_REAL_IP',
     'QUERY_STRING',
     'REMOTE_ADDR',
     '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
             ct_id = getattr(instance, ct_attname)
             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)
                 if fk_val is not None:
                     fk_dict[ct_id].add(fk_val)
@@ -129,13 +133,14 @@ class RestrictedGenericForeignKey(GenericForeignKey):
             if ct_id is None:
                 return None
             else:
-                model = self.get_content_type(
+                if model := self.get_content_type(
                     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 (
             ret_val,

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

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

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

@@ -86,6 +86,10 @@ class DummyModel(models.Model):
     charfield = models.CharField(
         max_length=10
     )
+    numberfield = models.IntegerField(
+        blank=True,
+        null=True
+    )
     choicefield = models.IntegerField(
         choices=(('A', 1), ('B', 2), ('C', 3))
     )
@@ -108,6 +112,7 @@ class BaseFilterSetTest(TestCase):
     """
     class DummyFilterSet(BaseFilterSet):
         charfield = django_filters.CharFilter()
+        numberfield = django_filters.NumberFilter()
         macaddressfield = MACAddressFilter()
         modelchoicefield = django_filters.ModelChoiceFilter(
             field_name='integerfield',  # We're pretending this is a ForeignKey field
@@ -132,6 +137,7 @@ class BaseFilterSetTest(TestCase):
             model = DummyModel
             fields = (
                 'charfield',
+                'numberfield',
                 'choicefield',
                 'datefield',
                 'datetimefield',
@@ -171,6 +177,25 @@ class BaseFilterSetTest(TestCase):
         self.assertEqual(self.filters['charfield__iew'].exclude, False)
         self.assertEqual(self.filters['charfield__niew'].lookup_expr, 'iendswith')
         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):
         self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter)

+ 0 - 1
netbox/virtualization/views.py

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

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